From 5d67f5fc9b23b826bf44282db655a5a425f4a6f6 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 22 Jul 2022 20:17:33 +0100 Subject: [PATCH 01/31] fix: update patch syntax --- plugin/src/helpers/files.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/plugin/src/helpers/files.ts b/plugin/src/helpers/files.ts index 73699bdd5f..1e7971abdf 100644 --- a/plugin/src/helpers/files.ts +++ b/plugin/src/helpers/files.ts @@ -331,13 +331,21 @@ const baseServerReplacements: Array<[string, string]> = [ const nextServerReplacements: Array<[string, string]> = [ [ - `getMiddlewareManifest() {\n if (!this.minimalMode) {`, - `getMiddlewareManifest() {\n if (!this.minimalMode && !process.env.NEXT_USE_NETLIFY_EDGE) {`, + `getMiddlewareManifest() {\n if (this.minimalMode) return null;`, + `getMiddlewareManifest() {\n if (this.minimalMode || process.env.NEXT_USE_NETLIFY_EDGE) return null;`, + ], + [ + `generateCatchAllMiddlewareRoute(devReady) {\n if (this.minimalMode) return []`, + `generateCatchAllMiddlewareRoute(devReady) {\n if (this.minimalMode || process.env.NEXT_USE_NETLIFY_EDGE) return [];`, ], [ `generateCatchAllMiddlewareRoute() {\n if (this.minimalMode) return undefined;`, `generateCatchAllMiddlewareRoute() {\n if (this.minimalMode || process.env.NEXT_USE_NETLIFY_EDGE) return undefined;`, ], + [ + `getMiddlewareManifest() {\n if (this.minimalMode) {`, + `getMiddlewareManifest() {\n if (!this.minimalMode && !process.env.NEXT_USE_NETLIFY_EDGE) {`, + ], ] export const patchNextFiles = async (root: string): Promise => { From efdb03d45549567abb2e925e9a29c8c035da982a Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 22 Jul 2022 20:18:12 +0100 Subject: [PATCH 02/31] feat: add support for rewriting middleware responses --- .prettierignore | 1 - demos/middleware/middleware.ts | 60 +- demos/middleware/netlify.toml | 789 ++++++++++++++++++++++++++- demos/middleware/next.config.js | 1 + demos/middleware/pages/index.js | 3 + demos/middleware/pages/static.js | 11 + plugin/src/templates/edge/runtime.ts | 20 +- plugin/src/templates/edge/utils.ts | 57 +- 8 files changed, 921 insertions(+), 21 deletions(-) create mode 100644 demos/middleware/pages/static.js diff --git a/.prettierignore b/.prettierignore index b626363432..bcac728718 100644 --- a/.prettierignore +++ b/.prettierignore @@ -21,6 +21,5 @@ node_modules lib tsconfig.json demos/nx-next-monorepo-demo -plugin/src/templates/edge plugin/CHANGELOG.md \ No newline at end of file diff --git a/demos/middleware/middleware.ts b/demos/middleware/middleware.ts index 2f53ce9657..765f774e6e 100644 --- a/demos/middleware/middleware.ts +++ b/demos/middleware/middleware.ts @@ -1,12 +1,68 @@ import { NextResponse } from 'next/server' -import { NextFetchEvent, NextRequest } from 'next/server' +import type { NextRequest } from 'next/server' -export function middleware(request: NextRequest, ev: NextFetchEvent) { +/** + * Supercharge your Next middleware with Netlify Edge Functions + */ +class NetlifyResponse { + static async next(request: NextRequest): Promise { + const context = (request.geo as any).__nf_context + if (!context) { + throw new Error('NetlifyResponse can only be used with Netlify Edge Functions') + } + const response: Response = await context.next() + return new NetlifyNextResponse(response) + } +} + +type NextDataTransform = >(props: T) => T + +// A NextReponse that will pass through the Netlify origin response +// We can't pass it through directly, because Next disallows returning a response body +class NetlifyNextResponse extends NextResponse { + private originResponse: Response + private transforms: NextDataTransform[] + constructor(originResponse: Response) { + super() + this.originResponse = originResponse + Object.defineProperty(this, 'transforms', { + 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.transforms.push(transform) + } + get headers(): Headers { + // If we have the origin response, we should use its headers + return this.originResponse?.headers || super.headers + } +} + +export async function middleware(request: NextRequest) { let response const { nextUrl: { pathname }, } = request + if (pathname.startsWith('/static')) { + // Unlike NextResponse.next(), this actually sends the request to the origin + const res = await NetlifyResponse.next(request) + res.transformData((data) => { + data.pageProps.message = `This was static but has been transformed in ${request.geo.country}` + return data + }) + + return res + } + if (pathname.startsWith('/cookies')) { response = NextResponse.next() response.cookies.set('netlifyCookie', 'true') diff --git a/demos/middleware/netlify.toml b/demos/middleware/netlify.toml index a0ab27257f..03705982e4 100644 --- a/demos/middleware/netlify.toml +++ b/demos/middleware/netlify.toml @@ -1,23 +1,792 @@ +[dev] +framework = "#static" + +[functions] + + [functions._ipx] + node_bundler = "nft" + + [functions.___netlify-handler] + included_files = [ + ".env", + ".env.local", + ".env.production", + ".env.production.local", + "./public/locales/**", + "./next-i18next.config.js", + ".next/server/**", + ".next/serverless/**", + ".next/*.json", + ".next/BUILD_ID", + ".next/static/chunks/webpack-middleware*.js", + "!.next/server/**/*.js.nft.json", + "!../../node_modules/next/dist/compiled/@ampproject/toolbox-optimizer/**/*", + "!../../node_modules/next/dist/server/lib/squoosh/**/*.wasm", + "!../../node_modules/next/dist/next-server/server/lib/squoosh/**/*.wasm", + "!../../node_modules/next/dist/compiled/webpack/bundle4.js", + "!../../node_modules/next/dist/compiled/webpack/bundle5.js", + "!../../node_modules/sharp/**/*" + ] + external_node_modules = [] + node_bundler = "nft" + + [functions.___netlify-odb-handler] + included_files = [ + ".env", + ".env.local", + ".env.production", + ".env.production.local", + "./public/locales/**", + "./next-i18next.config.js", + ".next/server/**", + ".next/serverless/**", + ".next/*.json", + ".next/BUILD_ID", + ".next/static/chunks/webpack-middleware*.js", + "!.next/server/**/*.js.nft.json", + "!../../node_modules/next/dist/compiled/@ampproject/toolbox-optimizer/**/*", + "!../../node_modules/next/dist/server/lib/squoosh/**/*.wasm", + "!../../node_modules/next/dist/next-server/server/lib/squoosh/**/*.wasm", + "!../../node_modules/next/dist/compiled/webpack/bundle4.js", + "!../../node_modules/next/dist/compiled/webpack/bundle5.js", + "!../../node_modules/sharp/**/*" + ] + external_node_modules = [] + node_bundler = "nft" + [build] command = "npm run build" publish = ".next" ignore = "if [ $CACHED_COMMIT_REF == $COMMIT_REF ]; then (exit 1); else git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF ../..; fi;" -[build.environment] -NEXT_USE_NETLIFY_EDGE = "true" + [build.environment] + NEXT_USE_NETLIFY_EDGE = "true" + NEXT_PRIVATE_TARGET = "server" [[plugins]] package = "../plugin-wrapper/" -# This is a fake plugin, that makes it run npm install [[plugins]] package = "@netlify/plugin-local-install-core" -[functions] -included_files = [ - "!node_modules/sharp/vendor/8.12.2/darwin-*/**/*", - "!node_modules/sharp/build/Release/sharp-darwin-*" -] +[[redirects]] +from = "/_next/static/*" +to = "/static/:splat" +status = 200 -[dev] -framework = "#static" +[[redirects]] +from = "/_next/image*" +to = "/_ipx/w_:width,q_:quality/:url" +status = 301 + + [redirects.query] + url = ":url" + w = ":width" + q = ":quality" + +[[redirects]] +from = "/_ipx/*" +to = "/.netlify/builders/_ipx" +status = 200 + +[[redirects]] +from = "/cache/*" +to = "/404.html" +status = 404 +force = true + +[[redirects]] +from = "/server/*" +to = "/404.html" +status = 404 +force = true + +[[redirects]] +from = "/serverless/*" +to = "/404.html" +status = 404 +force = true + +[[redirects]] +from = "/trace" +to = "/404.html" +status = 404 +force = true + +[[redirects]] +from = "/traces" +to = "/404.html" +status = 404 +force = true + +[[redirects]] +from = "/routes-manifest.json" +to = "/404.html" +status = 404 +force = true + +[[redirects]] +from = "/build-manifest.json" +to = "/404.html" +status = 404 +force = true + +[[redirects]] +from = "/prerender-manifest.json" +to = "/404.html" +status = 404 +force = true + +[[redirects]] +from = "/react-loadable-manifest.json" +to = "/404.html" +status = 404 +force = true + +[[redirects]] +from = "/BUILD_ID" +to = "/404.html" +status = 404 +force = true + +[[redirects]] +from = "/api" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[[redirects]] +from = "/api/*" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[[redirects]] +from = "/favicon.ico" +to = "/favicon.ico" +status = 200 + + [redirects.conditions] + Cookie = [ + "__prerender_bypass", + "__next_preview_data" + ] + +[[redirects]] +from = "/*" +to = "/.netlify/functions/___netlify-handler" +status = 200 +force = true + + [redirects.conditions] + Cookie = [ + "__prerender_bypass", + "__next_preview_data" + ] + +[[redirects]] +from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/index.json" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[[redirects]] +from = "/" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[[redirects]] +from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/cookies.json" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[[redirects]] +from = "/cookies" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[[redirects]] +from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows.json" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[[redirects]] +from = "/shows" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[[redirects]] +from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewrite-absolute.json" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[[redirects]] +from = "/shows/rewrite-absolute" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[[redirects]] +from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewrite-external.json" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[[redirects]] +from = "/shows/rewrite-external" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[[redirects]] +from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewriteme.json" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[[redirects]] +from = "/shows/rewriteme" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[[redirects]] +from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/static/:id.json" +to = "/.netlify/builders/___netlify-odb-handler" +status = 200 + +[[redirects]] +from = "/shows/static/:id" +to = "/.netlify/builders/___netlify-odb-handler" +status = 200 + +[[redirects]] +from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/:id.json" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[[redirects]] +from = "/shows/:id" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[[redirects]] +from = "/*" +to = "/.netlify/functions/___netlify-handler" +status = 200 + +[context] + + [context.production] + + [context.production.environment] + NEXT_PRIVATE_TARGET = "server" + + [context.production.functions] + + [context.production.functions._ipx] + node_bundler = "nft" + + [context.production.functions.___netlify-handler] + included_files = [ + ".env", + ".env.local", + ".env.production", + ".env.production.local", + "./public/locales/**", + "./next-i18next.config.js", + ".next/server/**", + ".next/serverless/**", + ".next/*.json", + ".next/BUILD_ID", + ".next/static/chunks/webpack-middleware*.js", + "!.next/server/**/*.js.nft.json", + "!../../node_modules/next/dist/compiled/@ampproject/toolbox-optimizer/**/*", + "!../../node_modules/next/dist/server/lib/squoosh/**/*.wasm", + "!../../node_modules/next/dist/next-server/server/lib/squoosh/**/*.wasm", + "!../../node_modules/next/dist/compiled/webpack/bundle4.js", + "!../../node_modules/next/dist/compiled/webpack/bundle5.js", + "!../../node_modules/sharp/**/*" + ] + external_node_modules = [] + node_bundler = "nft" + + [context.production.functions.___netlify-odb-handler] + included_files = [ + ".env", + ".env.local", + ".env.production", + ".env.production.local", + "./public/locales/**", + "./next-i18next.config.js", + ".next/server/**", + ".next/serverless/**", + ".next/*.json", + ".next/BUILD_ID", + ".next/static/chunks/webpack-middleware*.js", + "!.next/server/**/*.js.nft.json", + "!../../node_modules/next/dist/compiled/@ampproject/toolbox-optimizer/**/*", + "!../../node_modules/next/dist/server/lib/squoosh/**/*.wasm", + "!../../node_modules/next/dist/next-server/server/lib/squoosh/**/*.wasm", + "!../../node_modules/next/dist/compiled/webpack/bundle4.js", + "!../../node_modules/next/dist/compiled/webpack/bundle5.js", + "!../../node_modules/sharp/**/*" + ] + external_node_modules = [] + node_bundler = "nft" + + [context.production.build] + + [context.production.build.environment] + NEXT_PRIVATE_TARGET = "server" + + [[context.production.redirects]] + from = "/_next/static/*" + to = "/static/:splat" + status = 200 + + [[context.production.redirects]] + from = "/_next/image*" + to = "/_ipx/w_:width,q_:quality/:url" + status = 301 + + [context.production.redirects.query] + url = ":url" + w = ":width" + q = ":quality" + + [[context.production.redirects]] + from = "/_ipx/*" + to = "/.netlify/builders/_ipx" + status = 200 + + [[context.production.redirects]] + from = "/cache/*" + to = "/404.html" + status = 404 + force = true + + [[context.production.redirects]] + from = "/server/*" + to = "/404.html" + status = 404 + force = true + + [[context.production.redirects]] + from = "/serverless/*" + to = "/404.html" + status = 404 + force = true + + [[context.production.redirects]] + from = "/trace" + to = "/404.html" + status = 404 + force = true + + [[context.production.redirects]] + from = "/traces" + to = "/404.html" + status = 404 + force = true + + [[context.production.redirects]] + from = "/routes-manifest.json" + to = "/404.html" + status = 404 + force = true + + [[context.production.redirects]] + from = "/build-manifest.json" + to = "/404.html" + status = 404 + force = true + + [[context.production.redirects]] + from = "/prerender-manifest.json" + to = "/404.html" + status = 404 + force = true + + [[context.production.redirects]] + from = "/react-loadable-manifest.json" + to = "/404.html" + status = 404 + force = true + + [[context.production.redirects]] + from = "/BUILD_ID" + to = "/404.html" + status = 404 + force = true + + [[context.production.redirects]] + from = "/api" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.production.redirects]] + from = "/api/*" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.production.redirects]] + from = "/favicon.ico" + to = "/favicon.ico" + status = 200 + + [context.production.redirects.conditions] + Cookie = [ + "__prerender_bypass", + "__next_preview_data" + ] + + [[context.production.redirects]] + from = "/*" + to = "/.netlify/functions/___netlify-handler" + status = 200 + force = true + + [context.production.redirects.conditions] + Cookie = [ + "__prerender_bypass", + "__next_preview_data" + ] + + [[context.production.redirects]] + from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/index.json" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.production.redirects]] + from = "/" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.production.redirects]] + from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/cookies.json" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.production.redirects]] + from = "/cookies" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.production.redirects]] + from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows.json" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.production.redirects]] + from = "/shows" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.production.redirects]] + from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewrite-absolute.json" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.production.redirects]] + from = "/shows/rewrite-absolute" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.production.redirects]] + from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewrite-external.json" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.production.redirects]] + from = "/shows/rewrite-external" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.production.redirects]] + from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewriteme.json" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.production.redirects]] + from = "/shows/rewriteme" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.production.redirects]] + from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/static/:id.json" + to = "/.netlify/builders/___netlify-odb-handler" + status = 200 + + [[context.production.redirects]] + from = "/shows/static/:id" + to = "/.netlify/builders/___netlify-odb-handler" + status = 200 + + [[context.production.redirects]] + from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/:id.json" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.production.redirects]] + from = "/shows/:id" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.production.redirects]] + from = "/*" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [context.main] + + [context.main.environment] + NEXT_PRIVATE_TARGET = "server" + + [context.main.functions] + + [context.main.functions._ipx] + node_bundler = "nft" + + [context.main.functions.___netlify-handler] + included_files = [ + ".env", + ".env.local", + ".env.production", + ".env.production.local", + "./public/locales/**", + "./next-i18next.config.js", + ".next/server/**", + ".next/serverless/**", + ".next/*.json", + ".next/BUILD_ID", + ".next/static/chunks/webpack-middleware*.js", + "!.next/server/**/*.js.nft.json", + "!../../node_modules/next/dist/compiled/@ampproject/toolbox-optimizer/**/*", + "!../../node_modules/next/dist/server/lib/squoosh/**/*.wasm", + "!../../node_modules/next/dist/next-server/server/lib/squoosh/**/*.wasm", + "!../../node_modules/next/dist/compiled/webpack/bundle4.js", + "!../../node_modules/next/dist/compiled/webpack/bundle5.js", + "!../../node_modules/sharp/**/*" + ] + external_node_modules = [] + node_bundler = "nft" + + [context.main.functions.___netlify-odb-handler] + included_files = [ + ".env", + ".env.local", + ".env.production", + ".env.production.local", + "./public/locales/**", + "./next-i18next.config.js", + ".next/server/**", + ".next/serverless/**", + ".next/*.json", + ".next/BUILD_ID", + ".next/static/chunks/webpack-middleware*.js", + "!.next/server/**/*.js.nft.json", + "!../../node_modules/next/dist/compiled/@ampproject/toolbox-optimizer/**/*", + "!../../node_modules/next/dist/server/lib/squoosh/**/*.wasm", + "!../../node_modules/next/dist/next-server/server/lib/squoosh/**/*.wasm", + "!../../node_modules/next/dist/compiled/webpack/bundle4.js", + "!../../node_modules/next/dist/compiled/webpack/bundle5.js", + "!../../node_modules/sharp/**/*" + ] + external_node_modules = [] + node_bundler = "nft" + + [context.main.build] + + [context.main.build.environment] + NEXT_PRIVATE_TARGET = "server" + + [[context.main.redirects]] + from = "/_next/static/*" + to = "/static/:splat" + status = 200 + + [[context.main.redirects]] + from = "/_next/image*" + to = "/_ipx/w_:width,q_:quality/:url" + status = 301 + + [context.main.redirects.query] + url = ":url" + w = ":width" + q = ":quality" + + [[context.main.redirects]] + from = "/_ipx/*" + to = "/.netlify/builders/_ipx" + status = 200 + + [[context.main.redirects]] + from = "/cache/*" + to = "/404.html" + status = 404 + force = true + + [[context.main.redirects]] + from = "/server/*" + to = "/404.html" + status = 404 + force = true + + [[context.main.redirects]] + from = "/serverless/*" + to = "/404.html" + status = 404 + force = true + + [[context.main.redirects]] + from = "/trace" + to = "/404.html" + status = 404 + force = true + + [[context.main.redirects]] + from = "/traces" + to = "/404.html" + status = 404 + force = true + + [[context.main.redirects]] + from = "/routes-manifest.json" + to = "/404.html" + status = 404 + force = true + + [[context.main.redirects]] + from = "/build-manifest.json" + to = "/404.html" + status = 404 + force = true + + [[context.main.redirects]] + from = "/prerender-manifest.json" + to = "/404.html" + status = 404 + force = true + + [[context.main.redirects]] + from = "/react-loadable-manifest.json" + to = "/404.html" + status = 404 + force = true + + [[context.main.redirects]] + from = "/BUILD_ID" + to = "/404.html" + status = 404 + force = true + + [[context.main.redirects]] + from = "/api" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.main.redirects]] + from = "/api/*" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.main.redirects]] + from = "/favicon.ico" + to = "/favicon.ico" + status = 200 + + [context.main.redirects.conditions] + Cookie = [ + "__prerender_bypass", + "__next_preview_data" + ] + + [[context.main.redirects]] + from = "/*" + to = "/.netlify/functions/___netlify-handler" + status = 200 + force = true + + [context.main.redirects.conditions] + Cookie = [ + "__prerender_bypass", + "__next_preview_data" + ] + + [[context.main.redirects]] + from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/index.json" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.main.redirects]] + from = "/" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.main.redirects]] + from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/cookies.json" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.main.redirects]] + from = "/cookies" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.main.redirects]] + from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows.json" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.main.redirects]] + from = "/shows" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.main.redirects]] + from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewrite-absolute.json" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.main.redirects]] + from = "/shows/rewrite-absolute" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.main.redirects]] + from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewrite-external.json" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.main.redirects]] + from = "/shows/rewrite-external" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.main.redirects]] + from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewriteme.json" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.main.redirects]] + from = "/shows/rewriteme" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.main.redirects]] + from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/static/:id.json" + to = "/.netlify/builders/___netlify-odb-handler" + status = 200 + + [[context.main.redirects]] + from = "/shows/static/:id" + to = "/.netlify/builders/___netlify-odb-handler" + status = 200 + + [[context.main.redirects]] + from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/:id.json" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.main.redirects]] + from = "/shows/:id" + to = "/.netlify/functions/___netlify-handler" + status = 200 + + [[context.main.redirects]] + from = "/*" + to = "/.netlify/functions/___netlify-handler" + status = 200 \ No newline at end of file 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/pages/index.js b/demos/middleware/pages/index.js index 8d0905762d..1182a71180 100644 --- a/demos/middleware/pages/index.js +++ b/demos/middleware/pages/index.js @@ -33,6 +33,9 @@ export default function Home() { Cookie API

+

+ Rewrite static page content +

) diff --git a/demos/middleware/pages/static.js b/demos/middleware/pages/static.js new file mode 100644 index 0000000000..951a7f22a5 --- /dev/null +++ b/demos/middleware/pages/static.js @@ -0,0 +1,11 @@ +const Page = ({ message }) =>
{message}
+ +export async function getStaticProps(context) { + return { + props: { + message: 'This is a static page', + }, + } +} + +export default Page diff --git a/plugin/src/templates/edge/runtime.ts b/plugin/src/templates/edge/runtime.ts index def301d78a..db04060710 100644 --- a/plugin/src/templates/edge/runtime.ts +++ b/plugin/src/templates/edge/runtime.ts @@ -38,13 +38,23 @@ const handler = async (req: Request, context: Context) => { return } + const geo = { + country: context.geo.country?.code, + region: context.geo.subdivision?.code, + city: context.geo.city, + } + + // The geo object is passed through to the middleware unchanged + // so we're smuggling the Netlify context object inside it + + Object.defineProperty(geo, '__nf_context', { + value: context, + enumerable: false, + }) + 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, diff --git a/plugin/src/templates/edge/utils.ts b/plugin/src/templates/edge/utils.ts index 57230273f5..f809c6e986 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 { 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,12 @@ export const addMiddlewareHeaders = async ( }) return response } + +interface NetlifyNextResponse extends Response { + originResponse: Response + transforms: NextDataTransform[] +} + export const buildResponse = async ({ result, request, @@ -43,13 +51,56 @@ export const buildResponse = async ({ request: Request context: Context }) => { + // This means it's a Netlify Next response. + if ('transforms' in result.response) { + const response = result.response as NetlifyNextResponse + // 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.transforms.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().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.transforms.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() + } + }, + }) + return rewriter.transform(response.originResponse) + } const res = new Response(result.response.body, result.response) request.headers.set('x-nf-next-middleware', 'skip') + res.headers.forEach((value, key) => { + if (key.startsWith('x-request-header-')) { + request.headers.set(key.slice(17), value) + } + }) + 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) From e1c41e9180759741bec069f028795664672d0655 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 22 Jul 2022 20:19:02 +0100 Subject: [PATCH 03/31] chore: format --- plugin/src/templates/edge/ipx.ts | 55 +++++++++++++++----------------- 1 file changed, 25 insertions(+), 30 deletions(-) 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 From d9011d018918fb94a82efe9cdeab10348b8c80dc Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 23 Jul 2022 07:19:32 +0100 Subject: [PATCH 04/31] chore: add extra content --- demos/middleware/middleware.ts | 1 + demos/middleware/pages/static.js | 8 +++++++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/demos/middleware/middleware.ts b/demos/middleware/middleware.ts index 765f774e6e..68333d5285 100644 --- a/demos/middleware/middleware.ts +++ b/demos/middleware/middleware.ts @@ -57,6 +57,7 @@ export async function middleware(request: NextRequest) { const res = await NetlifyResponse.next(request) res.transformData((data) => { data.pageProps.message = `This was static but has been transformed in ${request.geo.country}` + data.pageProps.showAd = true return data }) diff --git a/demos/middleware/pages/static.js b/demos/middleware/pages/static.js index 951a7f22a5..d524baadd7 100644 --- a/demos/middleware/pages/static.js +++ b/demos/middleware/pages/static.js @@ -1,9 +1,15 @@ -const Page = ({ message }) =>
{message}
+const Page = ({ message, showAd }) => ( +
+

{message}

+ {showAd ?
This is an ad that isn't shown by default
:

No ads for me

} +
+) export async function getStaticProps(context) { return { props: { message: 'This is a static page', + showAd: false, }, } } From a2c88b7b5ce1fd0c70e6a4fbad9f0384c88847c2 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 23 Jul 2022 07:20:51 +0100 Subject: [PATCH 05/31] chore: use city in demo --- demos/middleware/middleware.ts | 2 +- demos/middleware/pages/static.js | 9 ++++++++- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/demos/middleware/middleware.ts b/demos/middleware/middleware.ts index 68333d5285..67c3bbffbf 100644 --- a/demos/middleware/middleware.ts +++ b/demos/middleware/middleware.ts @@ -56,7 +56,7 @@ export async function middleware(request: NextRequest) { // Unlike NextResponse.next(), this actually sends the request to the origin const res = await NetlifyResponse.next(request) res.transformData((data) => { - data.pageProps.message = `This was static but has been transformed in ${request.geo.country}` + data.pageProps.message = `This was static but has been transformed in ${request.geo.city}` data.pageProps.showAd = true return data }) diff --git a/demos/middleware/pages/static.js b/demos/middleware/pages/static.js index d524baadd7..6ecab25fdf 100644 --- a/demos/middleware/pages/static.js +++ b/demos/middleware/pages/static.js @@ -1,7 +1,14 @@ const Page = ({ message, showAd }) => (

{message}

- {showAd ?
This is an ad that isn't shown by default
:

No ads for me

} + {showAd ? ( +
+

This is an ad that isn't shown by default

+ +
+ ) : ( +

No ads for me

+ )}
) From ba284d934e50b80bab8800bf307318c5a2886341 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 23 Jul 2022 09:57:13 +0100 Subject: [PATCH 06/31] feat: add html rewriting --- demos/middleware/html_rewriter.d.ts | 85 +++++++++++++++++++++++++++++ demos/middleware/middleware.ts | 32 +++++++++-- demos/middleware/pages/static.js | 2 +- plugin/src/templates/edge/bundle.js | 6 +- plugin/src/templates/edge/utils.ts | 57 +++++++++++-------- 5 files changed, 150 insertions(+), 32 deletions(-) create mode 100644 demos/middleware/html_rewriter.d.ts diff --git a/demos/middleware/html_rewriter.d.ts b/demos/middleware/html_rewriter.d.ts new file mode 100644 index 0000000000..e429cf0df8 --- /dev/null +++ b/demos/middleware/html_rewriter.d.ts @@ -0,0 +1,85 @@ +export interface ContentTypeOptions { + html?: boolean +} + +export 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 class EndTag { + before(content: string, options?: ContentTypeOptions): this + after(content: string, options?: ContentTypeOptions): this + remove(): this + name: string +} + +export 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 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 class Doctype { + readonly name: string | null + readonly publicId: string | null + readonly systemId: string | null +} + +export 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 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 +} diff --git a/demos/middleware/middleware.ts b/demos/middleware/middleware.ts index 67c3bbffbf..6d88e7b110 100644 --- a/demos/middleware/middleware.ts +++ b/demos/middleware/middleware.ts @@ -1,5 +1,6 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' +import type { ElementHandlers } from './html_rewriter' /** * Supercharge your Next middleware with Netlify Edge Functions @@ -21,11 +22,18 @@ type NextDataTransform = >(props: T) => T // We can't pass it through directly, because Next disallows returning a response body class NetlifyNextResponse extends NextResponse { private originResponse: Response - private transforms: NextDataTransform[] + private dataTransforms: NextDataTransform[] + + private elementHandlers: Array<[selector: string, handlers: ElementHandlers]> constructor(originResponse: Response) { super() this.originResponse = originResponse - Object.defineProperty(this, 'transforms', { + Object.defineProperty(this, 'dataTransforms', { + value: [], + enumerable: false, + writable: false, + }) + Object.defineProperty(this, 'elementHandlers', { value: [], enumerable: false, writable: false, @@ -38,8 +46,13 @@ class NetlifyNextResponse extends NextResponse { */ transformData(transform: NextDataTransform) { // The transforms are evaluated after the middleware is returned - this.transforms.push(transform) + this.dataTransforms.push(transform) } + + rewriteHTML(selector: string, handlers: ElementHandlers) { + this.elementHandlers.push([selector, handlers]) + } + get headers(): Headers { // If we have the origin response, we should use its headers return this.originResponse?.headers || super.headers @@ -55,11 +68,22 @@ export async function middleware(request: NextRequest) { if (pathname.startsWith('/static')) { // Unlike NextResponse.next(), this actually sends the request to the origin const res = await NetlifyResponse.next(request) + const message = `This was static but has been transformed in ${request.geo.city}` + res.transformData((data) => { - data.pageProps.message = `This was static but has been transformed in ${request.geo.city}` + data.pageProps.message = message data.pageProps.showAd = true return data }) + res.rewriteHTML('p[id=message]', { + text(textChunk) { + if (textChunk.lastInTextNode) { + textChunk.replace(message) + } else { + textChunk.remove() + } + }, + }) return res } diff --git a/demos/middleware/pages/static.js b/demos/middleware/pages/static.js index 6ecab25fdf..2345a7b975 100644 --- a/demos/middleware/pages/static.js +++ b/demos/middleware/pages/static.js @@ -1,6 +1,6 @@ const Page = ({ message, showAd }) => (
-

{message}

+

{message}

{showAd ? (

This is an ad that isn't shown by default

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/utils.ts b/plugin/src/templates/edge/utils.ts index f809c6e986..72f7afbe33 100644 --- a/plugin/src/templates/edge/utils.ts +++ b/plugin/src/templates/edge/utils.ts @@ -1,5 +1,5 @@ import type { Context } from 'netlify:edge' -import { HTMLRewriter } from 'https://deno.land/x/html_rewriter@v0.1.0-pre.17/index.ts' +import { ElementHandlers, HTMLRewriter } from 'https://deno.land/x/html_rewriter@v0.1.0-pre.17/index.ts' export interface FetchEventResult { response: Response @@ -39,7 +39,8 @@ export const addMiddlewareHeaders = async ( interface NetlifyNextResponse extends Response { originResponse: Response - transforms: NextDataTransform[] + dataTransforms: NextDataTransform[] + elementHandlers: Array<[selector: string, handlers: ElementHandlers]> } export const buildResponse = async ({ @@ -52,12 +53,12 @@ export const buildResponse = async ({ context: Context }) => { // This means it's a Netlify Next response. - if ('transforms' in result.response) { + if ('dataTransforms' in result.response) { const response = result.response as NetlifyNextResponse // 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.transforms.reduce((prev, transform) => { + const transformed = response.dataTransforms.reduce((prev, transform) => { return transform(prev) }, props) return context.json(transformed) @@ -65,27 +66,35 @@ export const buildResponse = async ({ // 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().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.transforms.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) + 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() } - } 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) From d66e08e40211f1de2b6a18efdbb453685f9dcce2 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 23 Jul 2022 16:05:47 +0100 Subject: [PATCH 07/31] feat: add request header support --- demos/middleware/middleware.ts | 12 ++++++++++++ demos/middleware/pages/api/hello.js | 2 +- plugin/src/templates/edge/runtime.ts | 7 ++++++- plugin/src/templates/edge/utils.ts | 5 ----- 4 files changed, 19 insertions(+), 7 deletions(-) diff --git a/demos/middleware/middleware.ts b/demos/middleware/middleware.ts index 6d88e7b110..4b1e510ea2 100644 --- a/demos/middleware/middleware.ts +++ b/demos/middleware/middleware.ts @@ -8,9 +8,16 @@ import type { ElementHandlers } from './html_rewriter' class NetlifyResponse { static async next(request: NextRequest): Promise { const context = (request.geo as any).__nf_context + const originalRequest: Request = (request.geo as any).__nf_request + if (!context) { throw new Error('NetlifyResponse can only be used with Netlify Edge Functions') } + + request.headers.forEach((value, key) => { + originalRequest.headers.set(key, value) + }) + const response: Response = await context.next() return new NetlifyNextResponse(response) } @@ -88,6 +95,11 @@ export async function middleware(request: NextRequest) { return res } + if (pathname.startsWith('/api/hello')) { + request.headers.set('x-hello', 'world') + return NetlifyResponse.next(request) + } + if (pathname.startsWith('/cookies')) { response = NextResponse.next() response.cookies.set('netlifyCookie', 'true') 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/plugin/src/templates/edge/runtime.ts b/plugin/src/templates/edge/runtime.ts index db04060710..32ed52fc1a 100644 --- a/plugin/src/templates/edge/runtime.ts +++ b/plugin/src/templates/edge/runtime.ts @@ -45,13 +45,18 @@ const handler = async (req: Request, context: Context) => { } // The geo object is passed through to the middleware unchanged - // so we're smuggling the Netlify context object inside it + // so we're smuggling the Request and Netlify context object inside it Object.defineProperty(geo, '__nf_context', { value: context, enumerable: false, }) + Object.defineProperty(geo, '__nf_request', { + value: req, + enumerable: false, + }) + const request: RequestData = { headers: Object.fromEntries(req.headers.entries()), geo, diff --git a/plugin/src/templates/edge/utils.ts b/plugin/src/templates/edge/utils.ts index 72f7afbe33..d8ff75ca0e 100644 --- a/plugin/src/templates/edge/utils.ts +++ b/plugin/src/templates/edge/utils.ts @@ -99,11 +99,6 @@ export const buildResponse = async ({ } const res = new Response(result.response.body, result.response) request.headers.set('x-nf-next-middleware', 'skip') - res.headers.forEach((value, key) => { - if (key.startsWith('x-request-header-')) { - request.headers.set(key.slice(17), value) - } - }) const rewrite = res.headers.get('x-middleware-rewrite') if (rewrite) { From 55b5282abb0cde4f3ee10edefe0a815e32ab767d Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sat, 23 Jul 2022 16:46:48 +0100 Subject: [PATCH 08/31] feat: add rewriting --- demos/middleware/middleware.ts | 47 ++++++++++++++++++++++++---------- 1 file changed, 33 insertions(+), 14 deletions(-) diff --git a/demos/middleware/middleware.ts b/demos/middleware/middleware.ts index 4b1e510ea2..b83cde0b49 100644 --- a/demos/middleware/middleware.ts +++ b/demos/middleware/middleware.ts @@ -1,26 +1,42 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' import type { ElementHandlers } from './html_rewriter' +import { NextURL } from 'next/dist/server/web/next-url' + +type Context = any /** * Supercharge your Next middleware with Netlify Edge Functions */ class NetlifyResponse { - static async next(request: NextRequest): Promise { - const context = (request.geo as any).__nf_context - const originalRequest: Request = (request.geo as any).__nf_request + context: Context + originalRequest: Request + + constructor(public request: NextRequest) { + this.context = (request.geo as any).__nf_context + this.originalRequest = (request.geo as any).__nf_request - if (!context) { + if (!this.context) { throw new Error('NetlifyResponse can only be used with Netlify Edge Functions') } - - request.headers.forEach((value, key) => { - originalRequest.headers.set(key, value) + } + async next(): Promise { + this.request.headers.forEach((value, key) => { + this.originalRequest.headers.set(key, value) }) - - const response: Response = await context.next() + const response: Response = await this.context.next() return new NetlifyNextResponse(response) } + + rewrite(destination: string | URL | NextURL, init?: ResponseInit): NextResponse { + if (typeof destination === 'string' && destination.startsWith('/')) { + destination = new URL(destination, this.request.url) + } + this.request.headers.forEach((value, key) => { + this.originalRequest.headers.set(key, value) + }) + return NextResponse.rewrite(destination, init) + } } type NextDataTransform = >(props: T) => T @@ -28,11 +44,9 @@ type NextDataTransform = >(props: T) => T // A NextReponse that will pass through the Netlify origin response // We can't pass it through directly, because Next disallows returning a response body class NetlifyNextResponse extends NextResponse { - private originResponse: Response private dataTransforms: NextDataTransform[] - private elementHandlers: Array<[selector: string, handlers: ElementHandlers]> - constructor(originResponse: Response) { + constructor(public originResponse: Response) { super() this.originResponse = originResponse Object.defineProperty(this, 'dataTransforms', { @@ -74,7 +88,7 @@ export async function middleware(request: NextRequest) { if (pathname.startsWith('/static')) { // Unlike NextResponse.next(), this actually sends the request to the origin - const res = await NetlifyResponse.next(request) + const res = await new NetlifyResponse(request).next() const message = `This was static but has been transformed in ${request.geo.city}` res.transformData((data) => { @@ -97,7 +111,12 @@ export async function middleware(request: NextRequest) { if (pathname.startsWith('/api/hello')) { request.headers.set('x-hello', 'world') - return NetlifyResponse.next(request) + return new NetlifyResponse(request).next() + } + + if (pathname.startsWith('/headers')) { + request.headers.set('x-hello', 'world') + return new NetlifyResponse(request).rewrite('/api/hello') } if (pathname.startsWith('/cookies')) { From 426a116cef2e7724c1627d94154c43491535e33d Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 24 Jul 2022 10:26:01 +0100 Subject: [PATCH 09/31] chore: move NetlifyReponse into a subpackage --- .eslintignore | 4 +- .gitignore | 1 + demos/middleware/middleware.ts | 79 +--------------- demos/middleware/package.json | 4 +- package-lock.json | 58 +++++------- plugin/package.json | 16 +++- plugin/src/middleware/html-rewriter.ts | 89 +++++++++++++++++++ plugin/src/middleware/index.ts | 3 + .../src/middleware/netlify-next-response.ts | 50 +++++++++++ plugin/src/middleware/netlify-response.ts | 61 +++++++++++++ plugin/tsconfig.json | 3 + tsconfig.json | 10 ++- 12 files changed, 259 insertions(+), 119 deletions(-) create mode 100644 plugin/src/middleware/html-rewriter.ts create mode 100644 plugin/src/middleware/index.ts create mode 100644 plugin/src/middleware/netlify-next-response.ts create mode 100644 plugin/src/middleware/netlify-response.ts 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/.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/demos/middleware/middleware.ts b/demos/middleware/middleware.ts index b83cde0b49..9716e2294f 100644 --- a/demos/middleware/middleware.ts +++ b/demos/middleware/middleware.ts @@ -1,84 +1,7 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' -import type { ElementHandlers } from './html_rewriter' -import { NextURL } from 'next/dist/server/web/next-url' -type Context = any - -/** - * Supercharge your Next middleware with Netlify Edge Functions - */ -class NetlifyResponse { - context: Context - originalRequest: Request - - constructor(public request: NextRequest) { - this.context = (request.geo as any).__nf_context - this.originalRequest = (request.geo as any).__nf_request - - if (!this.context) { - throw new Error('NetlifyResponse can only be used with Netlify Edge Functions') - } - } - async next(): Promise { - this.request.headers.forEach((value, key) => { - this.originalRequest.headers.set(key, value) - }) - const response: Response = await this.context.next() - return new NetlifyNextResponse(response) - } - - rewrite(destination: string | URL | NextURL, init?: ResponseInit): NextResponse { - if (typeof destination === 'string' && destination.startsWith('/')) { - destination = new URL(destination, this.request.url) - } - this.request.headers.forEach((value, key) => { - this.originalRequest.headers.set(key, value) - }) - return NextResponse.rewrite(destination, init) - } -} - -type NextDataTransform = >(props: T) => T - -// A NextReponse that will pass through the Netlify origin response -// We can't pass it through directly, because Next disallows returning a response body -class NetlifyNextResponse extends NextResponse { - private dataTransforms: NextDataTransform[] - private elementHandlers: Array<[selector: string, handlers: ElementHandlers]> - constructor(public originResponse: Response) { - super() - this.originResponse = originResponse - 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) - } - - rewriteHTML(selector: string, handlers: ElementHandlers) { - this.elementHandlers.push([selector, handlers]) - } - - get headers(): Headers { - // If we have the origin response, we should use its headers - return this.originResponse?.headers || super.headers - } -} +import { NetlifyResponse } from '@netlify/plugin-nextjs/middleware' export async function middleware(request: NextRequest) { let response 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/package-lock.json b/package-lock.json index a6d1ac284f..1c10a9efe3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -186,12 +186,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", @@ -4858,13 +4858,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": "*", @@ -4884,7 +4884,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", @@ -8400,7 +8400,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", @@ -12513,7 +12513,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", @@ -19563,7 +19563,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", @@ -22610,7 +22610,7 @@ }, "plugin": { "name": "@netlify/plugin-nextjs", - "version": "4.12.2", + "version": "4.13.0", "license": "ISC", "dependencies": { "@netlify/functions": "^1.0.0", @@ -26000,13 +26000,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": "*", @@ -26026,7 +26026,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", @@ -26333,8 +26333,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", @@ -28675,7 +28674,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", @@ -29842,15 +29841,13 @@ "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-config-standard": { "version": "16.0.3", "resolved": "https://registry.npmjs.org/eslint-config-standard/-/eslint-config-standard-16.0.3.tgz", "integrity": "sha512-x4fmJL5hGqNJKGHSjnLdgA6U6h1YW/G2dW9fA+cyVur4SK6lyue8+UgNKWlZtUDTXvgKDD/Oa3GQjmB5kjtVvg==", - "dev": true, - "requires": {} + "dev": true }, "eslint-import-resolver-node": { "version": "0.3.6", @@ -30305,8 +30302,7 @@ "version": "5.2.0", "resolved": "https://registry.npmjs.org/eslint-plugin-promise/-/eslint-plugin-promise-5.2.0.tgz", "integrity": "sha512-SftLb1pUG01QYq2A/hGAWfDRXqYD82zE7j7TopDOyNdU+7SvvoXREls/+PRTY17vUXzXnZA/zfnyKgRH6x4JJw==", - "dev": true, - "requires": {} + "dev": true }, "eslint-plugin-react": { "version": "7.29.4", @@ -30354,8 +30350,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": "40.1.0", @@ -31820,7 +31815,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", @@ -32892,8 +32887,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", @@ -37155,7 +37149,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", @@ -38094,8 +38088,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", @@ -38871,8 +38864,7 @@ "ws": { "version": "8.5.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.5.0.tgz", - "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==", - "requires": {} + "integrity": "sha512-BWX0SWVgLPzYwF8lTzEy1egjhS4S4OEAHfsO8o65WOVsrnSRGaSiUaa9e0ggGlkMTtBlmOpEXiie9RUcBO86qg==" } } }, @@ -38980,8 +38972,7 @@ "use-sync-external-store": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.1.0.tgz", - "integrity": "sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ==", - "requires": {} + "integrity": "sha512-SEnieB2FPKEVne66NpXPd1Np4R1lTNKfjuy3XdIoPQKYBAFdzbzSZlSn1KJZUiihQLQC5Znot4SBz1EOTBwQAQ==" }, "util-deprecate": { "version": "1.0.2", @@ -39386,8 +39377,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 94427fe050..5330c4729c 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", @@ -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..2b21b46a8c --- /dev/null +++ b/plugin/src/middleware/html-rewriter.ts @@ -0,0 +1,89 @@ +/* eslint-disable max-classes-per-file */ + +// These types are inlined from the HTMLRewriter package, because we don't use the actual package here +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..1c4c9dd364 --- /dev/null +++ b/plugin/src/middleware/index.ts @@ -0,0 +1,3 @@ +export * from './netlify-next-response' +export * from './netlify-response' +export * from './html-rewriter' diff --git a/plugin/src/middleware/netlify-next-response.ts b/plugin/src/middleware/netlify-next-response.ts new file mode 100644 index 0000000000..0dd3ddcf7f --- /dev/null +++ b/plugin/src/middleware/netlify-next-response.ts @@ -0,0 +1,50 @@ +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 NextReponse that wraps the Netlify origin response +// We can't pass it through directly, because Next disallows returning a response body +export class NetlifyNextResponse extends NextResponse { + private readonly dataTransforms: NextDataTransform[] + private readonly elementHandlers: Array<[selector: string, handlers: ElementHandlers]> + constructor(public originResponse: Response) { + super() + this.originResponse = originResponse + + // 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]) + } + + 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/middleware/netlify-response.ts b/plugin/src/middleware/netlify-response.ts new file mode 100644 index 0000000000..995a3ae57c --- /dev/null +++ b/plugin/src/middleware/netlify-response.ts @@ -0,0 +1,61 @@ +/* eslint-disable no-underscore-dangle */ +import { NextURL } from 'next/dist/server/web/next-url' +import { NextResponse } from 'next/server' +import type { NextRequest } from 'next/server' + +import { NetlifyNextResponse } from './netlify-next-response' + +// TODO: add Context type +type Context = { + next: () => Promise +} + +// We sneak our own request and context into the middleware using the geo object +type AugmentedGeo = NextRequest['geo'] & { + // eslint-disable-next-line camelcase + __nf_context: Context + // eslint-disable-next-line camelcase + __nf_request: Request +} + +/** + * Supercharge your Next middleware with Netlify Edge Functions + */ +export class NetlifyResponse { + context: Context + originalRequest: Request + + constructor(public request: NextRequest) { + if (!('Deno' in globalThis)) { + throw new Error('NetlifyResponse only works in a Netlify Edge Function environment') + } + const geo = request.geo as AugmentedGeo + if (!geo) { + throw new Error('NetlifyResponse must be instantiated with a NextRequest object') + } + this.context = geo.__nf_context + this.originalRequest = geo.__nf_request + } + + // Add the headers to the original request, which will be passed to the origin + private applyHeaders() { + this.request.headers.forEach((value, name) => { + this.originalRequest.headers.set(name, value) + }) + } + + async next(): Promise { + this.applyHeaders() + const response = await this.context.next() + return new NetlifyNextResponse(response) + } + + rewrite(destination: string | URL | NextURL, init?: ResponseInit): NextResponse { + if (typeof destination === 'string' && destination.startsWith('/')) { + destination = new URL(destination, this.request.url) + } + this.applyHeaders() + return NextResponse.rewrite(destination, init) + } +} +/* eslint-enable no-underscore-dangle */ 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..d9e2dc6261 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. */ From 733e352edc4ec16c427ccd69af254f795d47f68c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 24 Jul 2022 10:26:17 +0100 Subject: [PATCH 10/31] feat: allow returning `NetlifyReponse` directly --- plugin/src/templates/edge/utils.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/plugin/src/templates/edge/utils.ts b/plugin/src/templates/edge/utils.ts index d8ff75ca0e..f388535495 100644 --- a/plugin/src/templates/edge/utils.ts +++ b/plugin/src/templates/edge/utils.ts @@ -43,6 +43,14 @@ interface NetlifyNextResponse extends Response { elementHandlers: Array<[selector: string, handlers: ElementHandlers]> } +interface NetlifyResponse { + request: Request + context: Context + originalRequest: Request + next(): Promise + rewrite(destination: string | URL, init?: ResponseInit): Response +} + export const buildResponse = async ({ result, request, @@ -52,6 +60,10 @@ export const buildResponse = async ({ request: Request context: Context }) => { + // They've returned the NetlifyResponse directly, so we'll call `next()` for them. + if ('originalRequest' in result.response) { + result.response = await (result.response as unknown as NetlifyResponse).next() + } // This means it's a Netlify Next response. if ('dataTransforms' in result.response) { const response = result.response as NetlifyNextResponse From 138c225d4152bf42f43d2ed53632ce6f00a7f5ee Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 24 Jul 2022 10:28:10 +0100 Subject: [PATCH 11/31] chore: remove inlined types from middleware demo --- demos/middleware/html_rewriter.d.ts | 85 ----------------------------- 1 file changed, 85 deletions(-) delete mode 100644 demos/middleware/html_rewriter.d.ts diff --git a/demos/middleware/html_rewriter.d.ts b/demos/middleware/html_rewriter.d.ts deleted file mode 100644 index e429cf0df8..0000000000 --- a/demos/middleware/html_rewriter.d.ts +++ /dev/null @@ -1,85 +0,0 @@ -export interface ContentTypeOptions { - html?: boolean -} - -export 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 class EndTag { - before(content: string, options?: ContentTypeOptions): this - after(content: string, options?: ContentTypeOptions): this - remove(): this - name: string -} - -export 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 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 class Doctype { - readonly name: string | null - readonly publicId: string | null - readonly systemId: string | null -} - -export 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 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 -} From 0256f25227b255ed7bcf245f1bd91cd2ae9d90e4 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 24 Jul 2022 10:30:13 +0100 Subject: [PATCH 12/31] chore: remove modified toml --- demos/middleware/netlify.toml | 789 +--------------------------------- 1 file changed, 10 insertions(+), 779 deletions(-) diff --git a/demos/middleware/netlify.toml b/demos/middleware/netlify.toml index 03705982e4..a0ab27257f 100644 --- a/demos/middleware/netlify.toml +++ b/demos/middleware/netlify.toml @@ -1,792 +1,23 @@ -[dev] -framework = "#static" - -[functions] - - [functions._ipx] - node_bundler = "nft" - - [functions.___netlify-handler] - included_files = [ - ".env", - ".env.local", - ".env.production", - ".env.production.local", - "./public/locales/**", - "./next-i18next.config.js", - ".next/server/**", - ".next/serverless/**", - ".next/*.json", - ".next/BUILD_ID", - ".next/static/chunks/webpack-middleware*.js", - "!.next/server/**/*.js.nft.json", - "!../../node_modules/next/dist/compiled/@ampproject/toolbox-optimizer/**/*", - "!../../node_modules/next/dist/server/lib/squoosh/**/*.wasm", - "!../../node_modules/next/dist/next-server/server/lib/squoosh/**/*.wasm", - "!../../node_modules/next/dist/compiled/webpack/bundle4.js", - "!../../node_modules/next/dist/compiled/webpack/bundle5.js", - "!../../node_modules/sharp/**/*" - ] - external_node_modules = [] - node_bundler = "nft" - - [functions.___netlify-odb-handler] - included_files = [ - ".env", - ".env.local", - ".env.production", - ".env.production.local", - "./public/locales/**", - "./next-i18next.config.js", - ".next/server/**", - ".next/serverless/**", - ".next/*.json", - ".next/BUILD_ID", - ".next/static/chunks/webpack-middleware*.js", - "!.next/server/**/*.js.nft.json", - "!../../node_modules/next/dist/compiled/@ampproject/toolbox-optimizer/**/*", - "!../../node_modules/next/dist/server/lib/squoosh/**/*.wasm", - "!../../node_modules/next/dist/next-server/server/lib/squoosh/**/*.wasm", - "!../../node_modules/next/dist/compiled/webpack/bundle4.js", - "!../../node_modules/next/dist/compiled/webpack/bundle5.js", - "!../../node_modules/sharp/**/*" - ] - external_node_modules = [] - node_bundler = "nft" - [build] command = "npm run build" publish = ".next" ignore = "if [ $CACHED_COMMIT_REF == $COMMIT_REF ]; then (exit 1); else git diff --quiet $CACHED_COMMIT_REF $COMMIT_REF ../..; fi;" - [build.environment] - NEXT_USE_NETLIFY_EDGE = "true" - NEXT_PRIVATE_TARGET = "server" +[build.environment] +NEXT_USE_NETLIFY_EDGE = "true" [[plugins]] package = "../plugin-wrapper/" +# This is a fake plugin, that makes it run npm install [[plugins]] package = "@netlify/plugin-local-install-core" -[[redirects]] -from = "/_next/static/*" -to = "/static/:splat" -status = 200 - -[[redirects]] -from = "/_next/image*" -to = "/_ipx/w_:width,q_:quality/:url" -status = 301 - - [redirects.query] - url = ":url" - w = ":width" - q = ":quality" - -[[redirects]] -from = "/_ipx/*" -to = "/.netlify/builders/_ipx" -status = 200 - -[[redirects]] -from = "/cache/*" -to = "/404.html" -status = 404 -force = true - -[[redirects]] -from = "/server/*" -to = "/404.html" -status = 404 -force = true - -[[redirects]] -from = "/serverless/*" -to = "/404.html" -status = 404 -force = true - -[[redirects]] -from = "/trace" -to = "/404.html" -status = 404 -force = true - -[[redirects]] -from = "/traces" -to = "/404.html" -status = 404 -force = true - -[[redirects]] -from = "/routes-manifest.json" -to = "/404.html" -status = 404 -force = true - -[[redirects]] -from = "/build-manifest.json" -to = "/404.html" -status = 404 -force = true - -[[redirects]] -from = "/prerender-manifest.json" -to = "/404.html" -status = 404 -force = true - -[[redirects]] -from = "/react-loadable-manifest.json" -to = "/404.html" -status = 404 -force = true - -[[redirects]] -from = "/BUILD_ID" -to = "/404.html" -status = 404 -force = true - -[[redirects]] -from = "/api" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[[redirects]] -from = "/api/*" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[[redirects]] -from = "/favicon.ico" -to = "/favicon.ico" -status = 200 - - [redirects.conditions] - Cookie = [ - "__prerender_bypass", - "__next_preview_data" - ] - -[[redirects]] -from = "/*" -to = "/.netlify/functions/___netlify-handler" -status = 200 -force = true - - [redirects.conditions] - Cookie = [ - "__prerender_bypass", - "__next_preview_data" - ] - -[[redirects]] -from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/index.json" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[[redirects]] -from = "/" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[[redirects]] -from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/cookies.json" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[[redirects]] -from = "/cookies" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[[redirects]] -from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows.json" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[[redirects]] -from = "/shows" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[[redirects]] -from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewrite-absolute.json" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[[redirects]] -from = "/shows/rewrite-absolute" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[[redirects]] -from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewrite-external.json" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[[redirects]] -from = "/shows/rewrite-external" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[[redirects]] -from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewriteme.json" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[[redirects]] -from = "/shows/rewriteme" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[[redirects]] -from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/static/:id.json" -to = "/.netlify/builders/___netlify-odb-handler" -status = 200 - -[[redirects]] -from = "/shows/static/:id" -to = "/.netlify/builders/___netlify-odb-handler" -status = 200 - -[[redirects]] -from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/:id.json" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[[redirects]] -from = "/shows/:id" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[[redirects]] -from = "/*" -to = "/.netlify/functions/___netlify-handler" -status = 200 - -[context] - - [context.production] - - [context.production.environment] - NEXT_PRIVATE_TARGET = "server" - - [context.production.functions] - - [context.production.functions._ipx] - node_bundler = "nft" - - [context.production.functions.___netlify-handler] - included_files = [ - ".env", - ".env.local", - ".env.production", - ".env.production.local", - "./public/locales/**", - "./next-i18next.config.js", - ".next/server/**", - ".next/serverless/**", - ".next/*.json", - ".next/BUILD_ID", - ".next/static/chunks/webpack-middleware*.js", - "!.next/server/**/*.js.nft.json", - "!../../node_modules/next/dist/compiled/@ampproject/toolbox-optimizer/**/*", - "!../../node_modules/next/dist/server/lib/squoosh/**/*.wasm", - "!../../node_modules/next/dist/next-server/server/lib/squoosh/**/*.wasm", - "!../../node_modules/next/dist/compiled/webpack/bundle4.js", - "!../../node_modules/next/dist/compiled/webpack/bundle5.js", - "!../../node_modules/sharp/**/*" - ] - external_node_modules = [] - node_bundler = "nft" - - [context.production.functions.___netlify-odb-handler] - included_files = [ - ".env", - ".env.local", - ".env.production", - ".env.production.local", - "./public/locales/**", - "./next-i18next.config.js", - ".next/server/**", - ".next/serverless/**", - ".next/*.json", - ".next/BUILD_ID", - ".next/static/chunks/webpack-middleware*.js", - "!.next/server/**/*.js.nft.json", - "!../../node_modules/next/dist/compiled/@ampproject/toolbox-optimizer/**/*", - "!../../node_modules/next/dist/server/lib/squoosh/**/*.wasm", - "!../../node_modules/next/dist/next-server/server/lib/squoosh/**/*.wasm", - "!../../node_modules/next/dist/compiled/webpack/bundle4.js", - "!../../node_modules/next/dist/compiled/webpack/bundle5.js", - "!../../node_modules/sharp/**/*" - ] - external_node_modules = [] - node_bundler = "nft" - - [context.production.build] - - [context.production.build.environment] - NEXT_PRIVATE_TARGET = "server" - - [[context.production.redirects]] - from = "/_next/static/*" - to = "/static/:splat" - status = 200 - - [[context.production.redirects]] - from = "/_next/image*" - to = "/_ipx/w_:width,q_:quality/:url" - status = 301 - - [context.production.redirects.query] - url = ":url" - w = ":width" - q = ":quality" - - [[context.production.redirects]] - from = "/_ipx/*" - to = "/.netlify/builders/_ipx" - status = 200 - - [[context.production.redirects]] - from = "/cache/*" - to = "/404.html" - status = 404 - force = true - - [[context.production.redirects]] - from = "/server/*" - to = "/404.html" - status = 404 - force = true - - [[context.production.redirects]] - from = "/serverless/*" - to = "/404.html" - status = 404 - force = true - - [[context.production.redirects]] - from = "/trace" - to = "/404.html" - status = 404 - force = true - - [[context.production.redirects]] - from = "/traces" - to = "/404.html" - status = 404 - force = true - - [[context.production.redirects]] - from = "/routes-manifest.json" - to = "/404.html" - status = 404 - force = true - - [[context.production.redirects]] - from = "/build-manifest.json" - to = "/404.html" - status = 404 - force = true - - [[context.production.redirects]] - from = "/prerender-manifest.json" - to = "/404.html" - status = 404 - force = true - - [[context.production.redirects]] - from = "/react-loadable-manifest.json" - to = "/404.html" - status = 404 - force = true - - [[context.production.redirects]] - from = "/BUILD_ID" - to = "/404.html" - status = 404 - force = true - - [[context.production.redirects]] - from = "/api" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.production.redirects]] - from = "/api/*" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.production.redirects]] - from = "/favicon.ico" - to = "/favicon.ico" - status = 200 - - [context.production.redirects.conditions] - Cookie = [ - "__prerender_bypass", - "__next_preview_data" - ] - - [[context.production.redirects]] - from = "/*" - to = "/.netlify/functions/___netlify-handler" - status = 200 - force = true - - [context.production.redirects.conditions] - Cookie = [ - "__prerender_bypass", - "__next_preview_data" - ] - - [[context.production.redirects]] - from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/index.json" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.production.redirects]] - from = "/" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.production.redirects]] - from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/cookies.json" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.production.redirects]] - from = "/cookies" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.production.redirects]] - from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows.json" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.production.redirects]] - from = "/shows" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.production.redirects]] - from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewrite-absolute.json" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.production.redirects]] - from = "/shows/rewrite-absolute" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.production.redirects]] - from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewrite-external.json" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.production.redirects]] - from = "/shows/rewrite-external" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.production.redirects]] - from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewriteme.json" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.production.redirects]] - from = "/shows/rewriteme" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.production.redirects]] - from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/static/:id.json" - to = "/.netlify/builders/___netlify-odb-handler" - status = 200 - - [[context.production.redirects]] - from = "/shows/static/:id" - to = "/.netlify/builders/___netlify-odb-handler" - status = 200 - - [[context.production.redirects]] - from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/:id.json" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.production.redirects]] - from = "/shows/:id" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.production.redirects]] - from = "/*" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [context.main] - - [context.main.environment] - NEXT_PRIVATE_TARGET = "server" - - [context.main.functions] - - [context.main.functions._ipx] - node_bundler = "nft" - - [context.main.functions.___netlify-handler] - included_files = [ - ".env", - ".env.local", - ".env.production", - ".env.production.local", - "./public/locales/**", - "./next-i18next.config.js", - ".next/server/**", - ".next/serverless/**", - ".next/*.json", - ".next/BUILD_ID", - ".next/static/chunks/webpack-middleware*.js", - "!.next/server/**/*.js.nft.json", - "!../../node_modules/next/dist/compiled/@ampproject/toolbox-optimizer/**/*", - "!../../node_modules/next/dist/server/lib/squoosh/**/*.wasm", - "!../../node_modules/next/dist/next-server/server/lib/squoosh/**/*.wasm", - "!../../node_modules/next/dist/compiled/webpack/bundle4.js", - "!../../node_modules/next/dist/compiled/webpack/bundle5.js", - "!../../node_modules/sharp/**/*" - ] - external_node_modules = [] - node_bundler = "nft" - - [context.main.functions.___netlify-odb-handler] - included_files = [ - ".env", - ".env.local", - ".env.production", - ".env.production.local", - "./public/locales/**", - "./next-i18next.config.js", - ".next/server/**", - ".next/serverless/**", - ".next/*.json", - ".next/BUILD_ID", - ".next/static/chunks/webpack-middleware*.js", - "!.next/server/**/*.js.nft.json", - "!../../node_modules/next/dist/compiled/@ampproject/toolbox-optimizer/**/*", - "!../../node_modules/next/dist/server/lib/squoosh/**/*.wasm", - "!../../node_modules/next/dist/next-server/server/lib/squoosh/**/*.wasm", - "!../../node_modules/next/dist/compiled/webpack/bundle4.js", - "!../../node_modules/next/dist/compiled/webpack/bundle5.js", - "!../../node_modules/sharp/**/*" - ] - external_node_modules = [] - node_bundler = "nft" - - [context.main.build] - - [context.main.build.environment] - NEXT_PRIVATE_TARGET = "server" - - [[context.main.redirects]] - from = "/_next/static/*" - to = "/static/:splat" - status = 200 - - [[context.main.redirects]] - from = "/_next/image*" - to = "/_ipx/w_:width,q_:quality/:url" - status = 301 - - [context.main.redirects.query] - url = ":url" - w = ":width" - q = ":quality" - - [[context.main.redirects]] - from = "/_ipx/*" - to = "/.netlify/builders/_ipx" - status = 200 - - [[context.main.redirects]] - from = "/cache/*" - to = "/404.html" - status = 404 - force = true - - [[context.main.redirects]] - from = "/server/*" - to = "/404.html" - status = 404 - force = true - - [[context.main.redirects]] - from = "/serverless/*" - to = "/404.html" - status = 404 - force = true - - [[context.main.redirects]] - from = "/trace" - to = "/404.html" - status = 404 - force = true - - [[context.main.redirects]] - from = "/traces" - to = "/404.html" - status = 404 - force = true - - [[context.main.redirects]] - from = "/routes-manifest.json" - to = "/404.html" - status = 404 - force = true - - [[context.main.redirects]] - from = "/build-manifest.json" - to = "/404.html" - status = 404 - force = true - - [[context.main.redirects]] - from = "/prerender-manifest.json" - to = "/404.html" - status = 404 - force = true - - [[context.main.redirects]] - from = "/react-loadable-manifest.json" - to = "/404.html" - status = 404 - force = true - - [[context.main.redirects]] - from = "/BUILD_ID" - to = "/404.html" - status = 404 - force = true - - [[context.main.redirects]] - from = "/api" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.main.redirects]] - from = "/api/*" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.main.redirects]] - from = "/favicon.ico" - to = "/favicon.ico" - status = 200 - - [context.main.redirects.conditions] - Cookie = [ - "__prerender_bypass", - "__next_preview_data" - ] - - [[context.main.redirects]] - from = "/*" - to = "/.netlify/functions/___netlify-handler" - status = 200 - force = true - - [context.main.redirects.conditions] - Cookie = [ - "__prerender_bypass", - "__next_preview_data" - ] - - [[context.main.redirects]] - from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/index.json" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.main.redirects]] - from = "/" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.main.redirects]] - from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/cookies.json" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.main.redirects]] - from = "/cookies" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.main.redirects]] - from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows.json" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.main.redirects]] - from = "/shows" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.main.redirects]] - from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewrite-absolute.json" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.main.redirects]] - from = "/shows/rewrite-absolute" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.main.redirects]] - from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewrite-external.json" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.main.redirects]] - from = "/shows/rewrite-external" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.main.redirects]] - from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/rewriteme.json" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.main.redirects]] - from = "/shows/rewriteme" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.main.redirects]] - from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/static/:id.json" - to = "/.netlify/builders/___netlify-odb-handler" - status = 200 - - [[context.main.redirects]] - from = "/shows/static/:id" - to = "/.netlify/builders/___netlify-odb-handler" - status = 200 - - [[context.main.redirects]] - from = "/_next/data/YLcitAOjPzcjTNIdWrYpy/shows/:id.json" - to = "/.netlify/functions/___netlify-handler" - status = 200 - - [[context.main.redirects]] - from = "/shows/:id" - to = "/.netlify/functions/___netlify-handler" - status = 200 +[functions] +included_files = [ + "!node_modules/sharp/vendor/8.12.2/darwin-*/**/*", + "!node_modules/sharp/build/Release/sharp-darwin-*" +] - [[context.main.redirects]] - from = "/*" - to = "/.netlify/functions/___netlify-handler" - status = 200 \ No newline at end of file +[dev] +framework = "#static" From f35a285a23d253bc103dd0e0b2855665aab46043 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Sun, 24 Jul 2022 10:41:57 +0100 Subject: [PATCH 13/31] chore: add demo links --- demos/middleware/pages/index.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/demos/middleware/pages/index.js b/demos/middleware/pages/index.js index 1182a71180..28906e2841 100644 --- a/demos/middleware/pages/index.js +++ b/demos/middleware/pages/index.js @@ -33,9 +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 +

) From e5df38554c03e2d50fb246f4c2bb5db3e46a1355 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 25 Jul 2022 06:54:14 +0100 Subject: [PATCH 14/31] chore: add comments to example --- demos/middleware/middleware.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/demos/middleware/middleware.ts b/demos/middleware/middleware.ts index 9716e2294f..cdad88a31b 100644 --- a/demos/middleware/middleware.ts +++ b/demos/middleware/middleware.ts @@ -14,11 +14,14 @@ export async function middleware(request: NextRequest) { const res = await new NetlifyResponse(request).next() const message = `This was static but has been transformed in ${request.geo.city}` + // Transform the response page data res.transformData((data) => { data.pageProps.message = message data.pageProps.showAd = true return data }) + + // Transform the response HTML res.rewriteHTML('p[id=message]', { text(textChunk) { if (textChunk.lastInTextNode) { @@ -33,11 +36,13 @@ export async function middleware(request: NextRequest) { } if (pathname.startsWith('/api/hello')) { + // Add a header to the request request.headers.set('x-hello', 'world') return new NetlifyResponse(request).next() } if (pathname.startsWith('/headers')) { + // Add a header to the rewritten request request.headers.set('x-hello', 'world') return new NetlifyResponse(request).rewrite('/api/hello') } From 2e1a4c61b9977756e0e3e1d47b001682dcf07b26 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 25 Jul 2022 11:27:30 +0100 Subject: [PATCH 15/31] chore: don't lint generated types --- .prettierignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.prettierignore b/.prettierignore index bcac728718..081d3085c9 100644 --- a/.prettierignore +++ b/.prettierignore @@ -22,4 +22,6 @@ lib tsconfig.json demos/nx-next-monorepo-demo -plugin/CHANGELOG.md \ No newline at end of file +plugin/CHANGELOG.md +plugin/lib +plugin/dist-types \ No newline at end of file From d2d16df4bc8377ab207d7e924b3b5b35af6bd68f Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Mon, 25 Jul 2022 13:34:27 +0100 Subject: [PATCH 16/31] chore: rename class --- demos/middleware/middleware.ts | 8 ++++---- plugin/src/middleware/index.ts | 2 +- .../{netlify-response.ts => netlify-middleware.ts} | 6 +++--- plugin/src/templates/edge/utils.ts | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) rename plugin/src/middleware/{netlify-response.ts => netlify-middleware.ts} (88%) diff --git a/demos/middleware/middleware.ts b/demos/middleware/middleware.ts index cdad88a31b..7f5df3b262 100644 --- a/demos/middleware/middleware.ts +++ b/demos/middleware/middleware.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' -import { NetlifyResponse } from '@netlify/plugin-nextjs/middleware' +import { NetlifyMiddleware } from '@netlify/plugin-nextjs/middleware' export async function middleware(request: NextRequest) { let response @@ -11,7 +11,7 @@ export async function middleware(request: NextRequest) { if (pathname.startsWith('/static')) { // Unlike NextResponse.next(), this actually sends the request to the origin - const res = await new NetlifyResponse(request).next() + const res = await new NetlifyMiddleware(request).next() const message = `This was static but has been transformed in ${request.geo.city}` // Transform the response page data @@ -38,13 +38,13 @@ export async function middleware(request: NextRequest) { if (pathname.startsWith('/api/hello')) { // Add a header to the request request.headers.set('x-hello', 'world') - return new NetlifyResponse(request).next() + return new NetlifyMiddleware(request).next() } if (pathname.startsWith('/headers')) { // Add a header to the rewritten request request.headers.set('x-hello', 'world') - return new NetlifyResponse(request).rewrite('/api/hello') + return new NetlifyMiddleware(request).rewrite('/api/hello') } if (pathname.startsWith('/cookies')) { diff --git a/plugin/src/middleware/index.ts b/plugin/src/middleware/index.ts index 1c4c9dd364..86e7daa46a 100644 --- a/plugin/src/middleware/index.ts +++ b/plugin/src/middleware/index.ts @@ -1,3 +1,3 @@ export * from './netlify-next-response' -export * from './netlify-response' +export * from './netlify-middleware' export * from './html-rewriter' diff --git a/plugin/src/middleware/netlify-response.ts b/plugin/src/middleware/netlify-middleware.ts similarity index 88% rename from plugin/src/middleware/netlify-response.ts rename to plugin/src/middleware/netlify-middleware.ts index 995a3ae57c..128cc6c5c4 100644 --- a/plugin/src/middleware/netlify-response.ts +++ b/plugin/src/middleware/netlify-middleware.ts @@ -21,17 +21,17 @@ type AugmentedGeo = NextRequest['geo'] & { /** * Supercharge your Next middleware with Netlify Edge Functions */ -export class NetlifyResponse { +export class NetlifyMiddleware { context: Context originalRequest: Request constructor(public request: NextRequest) { if (!('Deno' in globalThis)) { - throw new Error('NetlifyResponse only works in a Netlify Edge Function environment') + throw new Error('NetlifyMiddleware only works in a Netlify Edge Function environment') } const geo = request.geo as AugmentedGeo if (!geo) { - throw new Error('NetlifyResponse must be instantiated with a NextRequest object') + throw new Error('NetlifyMiddleware must be instantiated with a NextRequest object') } this.context = geo.__nf_context this.originalRequest = geo.__nf_request diff --git a/plugin/src/templates/edge/utils.ts b/plugin/src/templates/edge/utils.ts index f388535495..a17bfb8838 100644 --- a/plugin/src/templates/edge/utils.ts +++ b/plugin/src/templates/edge/utils.ts @@ -43,7 +43,7 @@ interface NetlifyNextResponse extends Response { elementHandlers: Array<[selector: string, handlers: ElementHandlers]> } -interface NetlifyResponse { +interface NetlifyMiddleware { request: Request context: Context originalRequest: Request @@ -60,9 +60,9 @@ export const buildResponse = async ({ request: Request context: Context }) => { - // They've returned the NetlifyResponse directly, so we'll call `next()` for them. + // They've returned the NetlifyMiddleware directly, so we'll call `next()` for them. if ('originalRequest' in result.response) { - result.response = await (result.response as unknown as NetlifyResponse).next() + result.response = await (result.response as unknown as NetlifyMiddleware).next() } // This means it's a Netlify Next response. if ('dataTransforms' in result.response) { From e0861cb2a4021d0014f4b3dc884b6131d3ec8a9c Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 26 Jul 2022 10:33:29 +0100 Subject: [PATCH 17/31] chore: add comment about source of htmlrewriter types --- plugin/src/middleware/html-rewriter.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/plugin/src/middleware/html-rewriter.ts b/plugin/src/middleware/html-rewriter.ts index 2b21b46a8c..c89115d02e 100644 --- a/plugin/src/middleware/html-rewriter.ts +++ b/plugin/src/middleware/html-rewriter.ts @@ -1,6 +1,8 @@ /* 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 } From 9d0f004587648ceac67a4da568a00135e0bf7c7f Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 26 Jul 2022 10:34:01 +0100 Subject: [PATCH 18/31] refactor: use type guards --- plugin/src/templates/edge/utils.ts | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/plugin/src/templates/edge/utils.ts b/plugin/src/templates/edge/utils.ts index a17bfb8838..68c57e4a5d 100644 --- a/plugin/src/templates/edge/utils.ts +++ b/plugin/src/templates/edge/utils.ts @@ -51,6 +51,14 @@ interface NetlifyMiddleware { rewrite(destination: string | URL, init?: ResponseInit): Response } +function isNetlifyMiddleware(response: Response | NetlifyMiddleware): response is NetlifyMiddleware { + return 'originalRequest' in response +} + +function isNetlifyNextResponse(response: Response | NetlifyNextResponse): response is NetlifyNextResponse { + return 'dataTransforms' in response +} + export const buildResponse = async ({ result, request, @@ -61,12 +69,11 @@ export const buildResponse = async ({ context: Context }) => { // They've returned the NetlifyMiddleware directly, so we'll call `next()` for them. - if ('originalRequest' in result.response) { - result.response = await (result.response as unknown as NetlifyMiddleware).next() + if (isNetlifyMiddleware(result.response)) { + result.response = await result.response.next() } - // This means it's a Netlify Next response. - if ('dataTransforms' in result.response) { - const response = result.response as NetlifyNextResponse + if (isNetlifyNextResponse(result.response)) { + const { response } = result // 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() From 8deaba46d62ec9c62ada3f356ace2d505755f05e Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 26 Jul 2022 10:45:33 +0100 Subject: [PATCH 19/31] chore: rename classes --- demos/middleware/middleware.ts | 8 ++++---- ...lify-middleware.ts => enhanced-middleware.ts} | 8 ++++---- ...ext-response.ts => enhanced-next-response.ts} | 2 +- plugin/src/middleware/index.ts | 4 ++-- plugin/src/templates/edge/utils.ts | 16 ++++++++-------- 5 files changed, 19 insertions(+), 19 deletions(-) rename plugin/src/middleware/{netlify-middleware.ts => enhanced-middleware.ts} (89%) rename plugin/src/middleware/{netlify-next-response.ts => enhanced-next-response.ts} (96%) diff --git a/demos/middleware/middleware.ts b/demos/middleware/middleware.ts index 7f5df3b262..06fa4129c5 100644 --- a/demos/middleware/middleware.ts +++ b/demos/middleware/middleware.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' -import { NetlifyMiddleware } from '@netlify/plugin-nextjs/middleware' +import { EnhancedMiddleware } from '@netlify/plugin-nextjs/middleware' export async function middleware(request: NextRequest) { let response @@ -11,7 +11,7 @@ export async function middleware(request: NextRequest) { if (pathname.startsWith('/static')) { // Unlike NextResponse.next(), this actually sends the request to the origin - const res = await new NetlifyMiddleware(request).next() + const res = await new EnhancedMiddleware(request).next() const message = `This was static but has been transformed in ${request.geo.city}` // Transform the response page data @@ -38,13 +38,13 @@ export async function middleware(request: NextRequest) { if (pathname.startsWith('/api/hello')) { // Add a header to the request request.headers.set('x-hello', 'world') - return new NetlifyMiddleware(request).next() + return new EnhancedMiddleware(request).next() } if (pathname.startsWith('/headers')) { // Add a header to the rewritten request request.headers.set('x-hello', 'world') - return new NetlifyMiddleware(request).rewrite('/api/hello') + return new EnhancedMiddleware(request).rewrite('/api/hello') } if (pathname.startsWith('/cookies')) { diff --git a/plugin/src/middleware/netlify-middleware.ts b/plugin/src/middleware/enhanced-middleware.ts similarity index 89% rename from plugin/src/middleware/netlify-middleware.ts rename to plugin/src/middleware/enhanced-middleware.ts index 128cc6c5c4..2bc6432353 100644 --- a/plugin/src/middleware/netlify-middleware.ts +++ b/plugin/src/middleware/enhanced-middleware.ts @@ -3,7 +3,7 @@ import { NextURL } from 'next/dist/server/web/next-url' import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' -import { NetlifyNextResponse } from './netlify-next-response' +import { EnhancedNextResponse } from './enhanced-next-response' // TODO: add Context type type Context = { @@ -21,7 +21,7 @@ type AugmentedGeo = NextRequest['geo'] & { /** * Supercharge your Next middleware with Netlify Edge Functions */ -export class NetlifyMiddleware { +export class EnhancedMiddleware { context: Context originalRequest: Request @@ -44,10 +44,10 @@ export class NetlifyMiddleware { }) } - async next(): Promise { + async next(): Promise { this.applyHeaders() const response = await this.context.next() - return new NetlifyNextResponse(response) + return new EnhancedNextResponse(response) } rewrite(destination: string | URL | NextURL, init?: ResponseInit): NextResponse { diff --git a/plugin/src/middleware/netlify-next-response.ts b/plugin/src/middleware/enhanced-next-response.ts similarity index 96% rename from plugin/src/middleware/netlify-next-response.ts rename to plugin/src/middleware/enhanced-next-response.ts index 0dd3ddcf7f..2c4663cb86 100644 --- a/plugin/src/middleware/netlify-next-response.ts +++ b/plugin/src/middleware/enhanced-next-response.ts @@ -7,7 +7,7 @@ export type NextDataTransform = >(props: T) => T // A NextReponse that wraps the Netlify origin response // We can't pass it through directly, because Next disallows returning a response body -export class NetlifyNextResponse extends NextResponse { +export class EnhancedNextResponse extends NextResponse { private readonly dataTransforms: NextDataTransform[] private readonly elementHandlers: Array<[selector: string, handlers: ElementHandlers]> constructor(public originResponse: Response) { diff --git a/plugin/src/middleware/index.ts b/plugin/src/middleware/index.ts index 86e7daa46a..8f9bba339f 100644 --- a/plugin/src/middleware/index.ts +++ b/plugin/src/middleware/index.ts @@ -1,3 +1,3 @@ -export * from './netlify-next-response' -export * from './netlify-middleware' +export * from './enhanced-next-response' +export * from './enhanced-middleware' export * from './html-rewriter' diff --git a/plugin/src/templates/edge/utils.ts b/plugin/src/templates/edge/utils.ts index 68c57e4a5d..dda32e33d0 100644 --- a/plugin/src/templates/edge/utils.ts +++ b/plugin/src/templates/edge/utils.ts @@ -37,25 +37,25 @@ export const addMiddlewareHeaders = async ( return response } -interface NetlifyNextResponse extends Response { +interface EnhancedNextResponse extends Response { originResponse: Response dataTransforms: NextDataTransform[] elementHandlers: Array<[selector: string, handlers: ElementHandlers]> } -interface NetlifyMiddleware { +interface EnhancedMiddleware { request: Request context: Context originalRequest: Request - next(): Promise + next(): Promise rewrite(destination: string | URL, init?: ResponseInit): Response } -function isNetlifyMiddleware(response: Response | NetlifyMiddleware): response is NetlifyMiddleware { +function isEnhancedMiddleware(response: Response | EnhancedMiddleware): response is EnhancedMiddleware { return 'originalRequest' in response } -function isNetlifyNextResponse(response: Response | NetlifyNextResponse): response is NetlifyNextResponse { +function isEnhancedNextResponse(response: Response | EnhancedNextResponse): response is EnhancedNextResponse { return 'dataTransforms' in response } @@ -68,11 +68,11 @@ export const buildResponse = async ({ request: Request context: Context }) => { - // They've returned the NetlifyMiddleware directly, so we'll call `next()` for them. - if (isNetlifyMiddleware(result.response)) { + // They've returned the EnhancedMiddleware directly, so we'll call `next()` for them. + if (isEnhancedMiddleware(result.response)) { result.response = await result.response.next() } - if (isNetlifyNextResponse(result.response)) { + if (isEnhancedNextResponse(result.response)) { const { response } = result // 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')) { From 52156884dd2e4f9fd47c85bd9ee933c15249dcdd Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 26 Jul 2022 11:17:02 +0100 Subject: [PATCH 20/31] chore: rename again --- demos/middleware/middleware.ts | 8 ++++---- plugin/src/middleware/index.ts | 4 ++-- ...anced-middleware.ts => middleware-request.ts} | 15 +++++++++++---- ...-next-response.ts => next-origin-response.ts} | 3 +-- plugin/src/templates/edge/utils.ts | 16 ++++++++-------- 5 files changed, 26 insertions(+), 20 deletions(-) rename plugin/src/middleware/{enhanced-middleware.ts => middleware-request.ts} (78%) rename plugin/src/middleware/{enhanced-next-response.ts => next-origin-response.ts} (94%) diff --git a/demos/middleware/middleware.ts b/demos/middleware/middleware.ts index 06fa4129c5..3aef48200f 100644 --- a/demos/middleware/middleware.ts +++ b/demos/middleware/middleware.ts @@ -1,7 +1,7 @@ import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' -import { EnhancedMiddleware } from '@netlify/plugin-nextjs/middleware' +import { MiddlewareRequest } from '@netlify/plugin-nextjs/middleware' export async function middleware(request: NextRequest) { let response @@ -11,7 +11,7 @@ export async function middleware(request: NextRequest) { if (pathname.startsWith('/static')) { // Unlike NextResponse.next(), this actually sends the request to the origin - const res = await new EnhancedMiddleware(request).next() + const res = await new MiddlewareRequest(request).next() const message = `This was static but has been transformed in ${request.geo.city}` // Transform the response page data @@ -38,13 +38,13 @@ export async function middleware(request: NextRequest) { if (pathname.startsWith('/api/hello')) { // Add a header to the request request.headers.set('x-hello', 'world') - return new EnhancedMiddleware(request).next() + return new MiddlewareRequest(request).next() } if (pathname.startsWith('/headers')) { // Add a header to the rewritten request request.headers.set('x-hello', 'world') - return new EnhancedMiddleware(request).rewrite('/api/hello') + return new MiddlewareRequest(request).rewrite('/api/hello') } if (pathname.startsWith('/cookies')) { diff --git a/plugin/src/middleware/index.ts b/plugin/src/middleware/index.ts index 8f9bba339f..d787e6096b 100644 --- a/plugin/src/middleware/index.ts +++ b/plugin/src/middleware/index.ts @@ -1,3 +1,3 @@ -export * from './enhanced-next-response' -export * from './enhanced-middleware' +export * from './next-origin-response' +export * from './middleware-request' export * from './html-rewriter' diff --git a/plugin/src/middleware/enhanced-middleware.ts b/plugin/src/middleware/middleware-request.ts similarity index 78% rename from plugin/src/middleware/enhanced-middleware.ts rename to plugin/src/middleware/middleware-request.ts index 2bc6432353..068e4b78dd 100644 --- a/plugin/src/middleware/enhanced-middleware.ts +++ b/plugin/src/middleware/middleware-request.ts @@ -3,7 +3,7 @@ import { NextURL } from 'next/dist/server/web/next-url' import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' -import { EnhancedNextResponse } from './enhanced-next-response' +import { NextOriginResponse } from './next-origin-response' // TODO: add Context type type Context = { @@ -21,7 +21,7 @@ type AugmentedGeo = NextRequest['geo'] & { /** * Supercharge your Next middleware with Netlify Edge Functions */ -export class EnhancedMiddleware { +export class MiddlewareRequest { context: Context originalRequest: Request @@ -44,10 +44,10 @@ export class EnhancedMiddleware { }) } - async next(): Promise { + async next(): Promise { this.applyHeaders() const response = await this.context.next() - return new EnhancedNextResponse(response) + return new NextOriginResponse(response) } rewrite(destination: string | URL | NextURL, init?: ResponseInit): NextResponse { @@ -57,5 +57,12 @@ export class EnhancedMiddleware { this.applyHeaders() return NextResponse.rewrite(destination, init) } + + redirect(destination: string | URL | NextURL, init?: number | ResponseInit) { + if (typeof destination === 'string' && destination.startsWith('/')) { + destination = new URL(destination, this.request.url) + } + return NextResponse.redirect(destination, init) + } } /* eslint-enable no-underscore-dangle */ diff --git a/plugin/src/middleware/enhanced-next-response.ts b/plugin/src/middleware/next-origin-response.ts similarity index 94% rename from plugin/src/middleware/enhanced-next-response.ts rename to plugin/src/middleware/next-origin-response.ts index 2c4663cb86..172636bf46 100644 --- a/plugin/src/middleware/enhanced-next-response.ts +++ b/plugin/src/middleware/next-origin-response.ts @@ -7,12 +7,11 @@ export type NextDataTransform = >(props: T) => T // A NextReponse that wraps the Netlify origin response // We can't pass it through directly, because Next disallows returning a response body -export class EnhancedNextResponse extends NextResponse { +export class NextOriginResponse extends NextResponse { private readonly dataTransforms: NextDataTransform[] private readonly elementHandlers: Array<[selector: string, handlers: ElementHandlers]> constructor(public originResponse: Response) { super() - this.originResponse = originResponse // These are private in Node when compiling, but we access them in Deno at runtime Object.defineProperty(this, 'dataTransforms', { diff --git a/plugin/src/templates/edge/utils.ts b/plugin/src/templates/edge/utils.ts index dda32e33d0..ba1c9cc3db 100644 --- a/plugin/src/templates/edge/utils.ts +++ b/plugin/src/templates/edge/utils.ts @@ -37,25 +37,25 @@ export const addMiddlewareHeaders = async ( return response } -interface EnhancedNextResponse extends Response { +interface NextOriginResponse extends Response { originResponse: Response dataTransforms: NextDataTransform[] elementHandlers: Array<[selector: string, handlers: ElementHandlers]> } -interface EnhancedMiddleware { +interface MiddlewareRequest { request: Request context: Context originalRequest: Request - next(): Promise + next(): Promise rewrite(destination: string | URL, init?: ResponseInit): Response } -function isEnhancedMiddleware(response: Response | EnhancedMiddleware): response is EnhancedMiddleware { +function isMiddlewareRequest(response: Response | MiddlewareRequest): response is MiddlewareRequest { return 'originalRequest' in response } -function isEnhancedNextResponse(response: Response | EnhancedNextResponse): response is EnhancedNextResponse { +function isNextOriginResponse(response: Response | NextOriginResponse): response is NextOriginResponse { return 'dataTransforms' in response } @@ -68,11 +68,11 @@ export const buildResponse = async ({ request: Request context: Context }) => { - // They've returned the EnhancedMiddleware directly, so we'll call `next()` for them. - if (isEnhancedMiddleware(result.response)) { + // They've returned the MiddlewareRequest directly, so we'll call `next()` for them. + if (isMiddlewareRequest(result.response)) { result.response = await result.response.next() } - if (isEnhancedNextResponse(result.response)) { + if (isNextOriginResponse(result.response)) { const { response } = result // 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')) { From 38543706c24d91d7b6878175229800ac62c48b28 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 26 Jul 2022 11:22:54 +0100 Subject: [PATCH 21/31] chore: rename again --- plugin/package.json | 2 +- plugin/src/middleware/index.ts | 4 ++-- .../src/middleware/{middleware-request.ts => request.ts} | 6 +++--- .../middleware/{next-origin-response.ts => response.ts} | 2 +- plugin/src/templates/edge/utils.ts | 8 ++++---- 5 files changed, 11 insertions(+), 11 deletions(-) rename plugin/src/middleware/{middleware-request.ts => request.ts} (93%) rename plugin/src/middleware/{next-origin-response.ts => response.ts} (96%) diff --git a/plugin/package.json b/plugin/package.json index 7c1075c0e8..20dbed05ac 100644 --- a/plugin/package.json +++ b/plugin/package.json @@ -53,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" diff --git a/plugin/src/middleware/index.ts b/plugin/src/middleware/index.ts index d787e6096b..ca57e04a4d 100644 --- a/plugin/src/middleware/index.ts +++ b/plugin/src/middleware/index.ts @@ -1,3 +1,3 @@ -export * from './next-origin-response' -export * from './middleware-request' +export * from './response' +export * from './request' export * from './html-rewriter' diff --git a/plugin/src/middleware/middleware-request.ts b/plugin/src/middleware/request.ts similarity index 93% rename from plugin/src/middleware/middleware-request.ts rename to plugin/src/middleware/request.ts index 068e4b78dd..ffab377353 100644 --- a/plugin/src/middleware/middleware-request.ts +++ b/plugin/src/middleware/request.ts @@ -3,7 +3,7 @@ import { NextURL } from 'next/dist/server/web/next-url' import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' -import { NextOriginResponse } from './next-origin-response' +import { MiddlewareResponse } from './response' // TODO: add Context type type Context = { @@ -44,10 +44,10 @@ export class MiddlewareRequest { }) } - async next(): Promise { + async next(): Promise { this.applyHeaders() const response = await this.context.next() - return new NextOriginResponse(response) + return new MiddlewareResponse(response) } rewrite(destination: string | URL | NextURL, init?: ResponseInit): NextResponse { diff --git a/plugin/src/middleware/next-origin-response.ts b/plugin/src/middleware/response.ts similarity index 96% rename from plugin/src/middleware/next-origin-response.ts rename to plugin/src/middleware/response.ts index 172636bf46..a04126d077 100644 --- a/plugin/src/middleware/next-origin-response.ts +++ b/plugin/src/middleware/response.ts @@ -7,7 +7,7 @@ export type NextDataTransform = >(props: T) => T // A NextReponse that wraps the Netlify origin response // We can't pass it through directly, because Next disallows returning a response body -export class NextOriginResponse extends NextResponse { +export class MiddlewareResponse extends NextResponse { private readonly dataTransforms: NextDataTransform[] private readonly elementHandlers: Array<[selector: string, handlers: ElementHandlers]> constructor(public originResponse: Response) { diff --git a/plugin/src/templates/edge/utils.ts b/plugin/src/templates/edge/utils.ts index ba1c9cc3db..39f07109d6 100644 --- a/plugin/src/templates/edge/utils.ts +++ b/plugin/src/templates/edge/utils.ts @@ -37,7 +37,7 @@ export const addMiddlewareHeaders = async ( return response } -interface NextOriginResponse extends Response { +interface MiddlewareResponse extends Response { originResponse: Response dataTransforms: NextDataTransform[] elementHandlers: Array<[selector: string, handlers: ElementHandlers]> @@ -47,7 +47,7 @@ interface MiddlewareRequest { request: Request context: Context originalRequest: Request - next(): Promise + next(): Promise rewrite(destination: string | URL, init?: ResponseInit): Response } @@ -55,7 +55,7 @@ function isMiddlewareRequest(response: Response | MiddlewareRequest): response i return 'originalRequest' in response } -function isNextOriginResponse(response: Response | NextOriginResponse): response is NextOriginResponse { +function isMiddlewareResponse(response: Response | MiddlewareResponse): response is MiddlewareResponse { return 'dataTransforms' in response } @@ -72,7 +72,7 @@ export const buildResponse = async ({ if (isMiddlewareRequest(result.response)) { result.response = await result.response.next() } - if (isNextOriginResponse(result.response)) { + if (isMiddlewareResponse(result.response)) { const { response } = result // 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')) { From e54fec6899a05e909d8cf1f1a7d74c118db6b3ee Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 26 Jul 2022 11:28:02 +0100 Subject: [PATCH 22/31] chore: update example --- demos/middleware/middleware.ts | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/demos/middleware/middleware.ts b/demos/middleware/middleware.ts index 3aef48200f..35d10297a4 100644 --- a/demos/middleware/middleware.ts +++ b/demos/middleware/middleware.ts @@ -3,16 +3,18 @@ import type { NextRequest } from 'next/server' import { MiddlewareRequest } from '@netlify/plugin-nextjs/middleware' -export async function middleware(request: NextRequest) { +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 new MiddlewareRequest(request).next() - const message = `This was static but has been transformed in ${request.geo.city}` + const res = await request.next() + const message = `This was static but has been transformed in ${req.geo.city}` // Transform the response page data res.transformData((data) => { @@ -37,14 +39,14 @@ export async function middleware(request: NextRequest) { if (pathname.startsWith('/api/hello')) { // Add a header to the request - request.headers.set('x-hello', 'world') - return new MiddlewareRequest(request).next() + req.headers.set('x-hello', 'world') + return request.next() } if (pathname.startsWith('/headers')) { // Add a header to the rewritten request - request.headers.set('x-hello', 'world') - return new MiddlewareRequest(request).rewrite('/api/hello') + req.headers.set('x-hello', 'world') + return request.rewrite('/api/hello') } if (pathname.startsWith('/cookies')) { @@ -55,7 +57,7 @@ export async function middleware(request: NextRequest) { 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')) { @@ -63,7 +65,7 @@ export async function middleware(request: NextRequest) { 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') From 468a0d3c4d915a06536feb231a1d886d0b0db372 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Tue, 26 Jul 2022 13:14:13 +0100 Subject: [PATCH 23/31] chore: make req a subclass of Request --- plugin/src/middleware/request.ts | 39 ++++++++++++++++++++++++-------- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/plugin/src/middleware/request.ts b/plugin/src/middleware/request.ts index ffab377353..02499c7995 100644 --- a/plugin/src/middleware/request.ts +++ b/plugin/src/middleware/request.ts @@ -21,15 +21,16 @@ type AugmentedGeo = NextRequest['geo'] & { /** * Supercharge your Next middleware with Netlify Edge Functions */ -export class MiddlewareRequest { +export class MiddlewareRequest extends Request { context: Context originalRequest: Request - constructor(public request: NextRequest) { + constructor(private nextRequest: NextRequest) { + super(nextRequest) if (!('Deno' in globalThis)) { throw new Error('NetlifyMiddleware only works in a Netlify Edge Function environment') } - const geo = request.geo as AugmentedGeo + const geo = nextRequest.geo as AugmentedGeo if (!geo) { throw new Error('NetlifyMiddleware must be instantiated with a NextRequest object') } @@ -39,7 +40,7 @@ export class MiddlewareRequest { // Add the headers to the original request, which will be passed to the origin private applyHeaders() { - this.request.headers.forEach((value, name) => { + this.headers.forEach((value, name) => { this.originalRequest.headers.set(name, value) }) } @@ -52,17 +53,35 @@ export class MiddlewareRequest { rewrite(destination: string | URL | NextURL, init?: ResponseInit): NextResponse { if (typeof destination === 'string' && destination.startsWith('/')) { - destination = new URL(destination, this.request.url) + destination = new URL(destination, this.url) } this.applyHeaders() return NextResponse.rewrite(destination, init) } - redirect(destination: string | URL | NextURL, init?: number | ResponseInit) { - if (typeof destination === 'string' && destination.startsWith('/')) { - destination = new URL(destination, this.request.url) - } - return NextResponse.redirect(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() } } + /* eslint-enable no-underscore-dangle */ From d4c0dc0548e47096deb9145873b4af48c1d6623d Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Wed, 27 Jul 2022 10:18:41 +0100 Subject: [PATCH 24/31] chore: switch from hidden fields to global map --- plugin/src/middleware/request.ts | 27 ++++++++------------ plugin/src/templates/edge/runtime.ts | 37 +++++++++++++++++++--------- 2 files changed, 35 insertions(+), 29 deletions(-) diff --git a/plugin/src/middleware/request.ts b/plugin/src/middleware/request.ts index 02499c7995..360eb452c2 100644 --- a/plugin/src/middleware/request.ts +++ b/plugin/src/middleware/request.ts @@ -1,4 +1,3 @@ -/* eslint-disable no-underscore-dangle */ import { NextURL } from 'next/dist/server/web/next-url' import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' @@ -10,14 +9,6 @@ type Context = { next: () => Promise } -// We sneak our own request and context into the middleware using the geo object -type AugmentedGeo = NextRequest['geo'] & { - // eslint-disable-next-line camelcase - __nf_context: Context - // eslint-disable-next-line camelcase - __nf_request: Request -} - /** * Supercharge your Next middleware with Netlify Edge Functions */ @@ -28,14 +19,18 @@ export class MiddlewareRequest extends Request { constructor(private nextRequest: NextRequest) { super(nextRequest) if (!('Deno' in globalThis)) { - throw new Error('NetlifyMiddleware only works in a Netlify Edge Function environment') + throw new Error('MiddlewareRequest only works in a Netlify Edge Function environment') } - const geo = nextRequest.geo as AugmentedGeo - if (!geo) { - throw new Error('NetlifyMiddleware must be instantiated with a NextRequest object') + const requestId = nextRequest.headers.get('x-nf-request-id') + if (!requestId) { + throw new Error('Missing x-nf-request-id header') } - this.context = geo.__nf_context - this.originalRequest = geo.__nf_request + 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 @@ -83,5 +78,3 @@ export class MiddlewareRequest extends Request { return this.nextRequest.url.toString() } } - -/* eslint-enable no-underscore-dangle */ diff --git a/plugin/src/templates/edge/runtime.ts b/plugin/src/templates/edge/runtime.ts index 32ed52fc1a..4f71b61001 100644 --- a/plugin/src/templates/edge/runtime.ts +++ b/plugin/src/templates/edge/runtime.ts @@ -32,6 +32,18 @@ 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/')) { @@ -44,18 +56,15 @@ const handler = async (req: Request, context: Context) => { city: context.geo.city, } - // The geo object is passed through to the middleware unchanged - // so we're smuggling the Request and Netlify context object inside it - - Object.defineProperty(geo, '__nf_context', { - value: context, - enumerable: false, - }) - - Object.defineProperty(geo, '__nf_request', { - value: req, - enumerable: false, - }) + 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()), @@ -72,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) + } } } From 0fd6c3354830fa0a479499f048a459643d729321 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 29 Jul 2022 10:24:59 +0100 Subject: [PATCH 25/31] ci: add cypress middleware tests --- .github/workflows/cypress-middleware.yml | 73 +++++++++++++++++++ cypress/config/middleware.json | 5 ++ .../middleware/rewrites-redirects.spec.ts | 15 ++++ package-lock.json | 3 +- plugin/src/templates/edge/runtime.ts | 2 +- tsconfig.json | 12 ++- 6 files changed, 105 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/cypress-middleware.yml create mode 100644 cypress/config/middleware.json create mode 100644 cypress/integration/middleware/rewrites-redirects.spec.ts 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/cypress/config/middleware.json b/cypress/config/middleware.json new file mode 100644 index 0000000000..2c8667cf46 --- /dev/null +++ b/cypress/config/middleware.json @@ -0,0 +1,5 @@ +{ + "baseUrl": "http://localhost:3000", + "integrationFolder": "cypress/integration/middleware", + "projectId": "yn8qwi" +} diff --git a/cypress/integration/middleware/rewrites-redirects.spec.ts b/cypress/integration/middleware/rewrites-redirects.spec.ts new file mode 100644 index 0000000000..faefdbf2dc --- /dev/null +++ b/cypress/integration/middleware/rewrites-redirects.spec.ts @@ -0,0 +1,15 @@ +describe('Rewrites and Redirects', () => { + it('rewrites to internal page', () => { + // preview mode is off by default + cy.visit('/shows/rewriteme') + cy.get('h1').should('contain', 'Shows #100') + cy.findByText('NextJS on Netlify (imported Header component)') + 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`) + }) +}) diff --git a/package-lock.json b/package-lock.json index 218b57bed9..1a72272197 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/plugin/src/templates/edge/runtime.ts b/plugin/src/templates/edge/runtime.ts index 4f71b61001..6fbb80af89 100644 --- a/plugin/src/templates/edge/runtime.ts +++ b/plugin/src/templates/edge/runtime.ts @@ -42,7 +42,7 @@ declare global { var NFRequestContextMap: Map } -globalThis.NFRequestContextMap = new Map() +globalThis.NFRequestContextMap ||= new Map() const handler = async (req: Request, context: Context) => { const url = new URL(req.url) diff --git a/tsconfig.json b/tsconfig.json index d9e2dc6261..a68cb8ff2b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -48,7 +48,7 @@ // "typeRoots": [], /* List of folders to include type definitions from. */ "types": [ "jest", - "node" + "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'. */, @@ -65,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 From 328a58459207ed0da917fcf6e0d6062b82fb2246 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 29 Jul 2022 11:32:47 +0100 Subject: [PATCH 26/31] ci: add tests for middleware headers --- cypress/config/middleware.json | 2 +- .../integration/middleware/rewrites-redirects.spec.ts | 11 +++++++++-- demos/middleware/netlify.toml | 10 ++++++++++ 3 files changed, 20 insertions(+), 3 deletions(-) diff --git a/cypress/config/middleware.json b/cypress/config/middleware.json index 2c8667cf46..c81506ae4b 100644 --- a/cypress/config/middleware.json +++ b/cypress/config/middleware.json @@ -1,5 +1,5 @@ { - "baseUrl": "http://localhost:3000", + "baseUrl": "http://localhost:8888", "integrationFolder": "cypress/integration/middleware", "projectId": "yn8qwi" } diff --git a/cypress/integration/middleware/rewrites-redirects.spec.ts b/cypress/integration/middleware/rewrites-redirects.spec.ts index faefdbf2dc..8174145b57 100644 --- a/cypress/integration/middleware/rewrites-redirects.spec.ts +++ b/cypress/integration/middleware/rewrites-redirects.spec.ts @@ -2,8 +2,7 @@ describe('Rewrites and Redirects', () => { it('rewrites to internal page', () => { // preview mode is off by default cy.visit('/shows/rewriteme') - cy.get('h1').should('contain', 'Shows #100') - cy.findByText('NextJS on Netlify (imported Header component)') + cy.get('h1').should('contain', 'Show #100') cy.url().should('eq', `${Cypress.config().baseUrl}/shows/rewriteme`) }) @@ -12,4 +11,12 @@ describe('Rewrites and Redirects', () => { 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/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 From 4f6a2aadc3ca288d711ea7aa3bd07752df912f6d Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 29 Jul 2022 12:07:22 +0100 Subject: [PATCH 27/31] ci: add tests for enhanced middleware --- .../integration/middleware/enhanced.spec.ts | 30 +++++++++++++++++++ ...tes-redirects.spec.ts => standard.spec.ts} | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) create mode 100644 cypress/integration/middleware/enhanced.spec.ts rename cypress/integration/middleware/{rewrites-redirects.spec.ts => standard.spec.ts} (94%) diff --git a/cypress/integration/middleware/enhanced.spec.ts b/cypress/integration/middleware/enhanced.spec.ts new file mode 100644 index 0000000000..537b6f7431 --- /dev/null +++ b/cypress/integration/middleware/enhanced.spec.ts @@ -0,0 +1,30 @@ +describe('Enhanced middleware', () => { + it('adds request headers', () => { + cy.request('/api/hello').then((response) => { + response.body.json().then((body) => { + expect(body.headers).to.have.property('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.findByText('This was static but has been transformed in') + cy.findByText("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/rewrites-redirects.spec.ts b/cypress/integration/middleware/standard.spec.ts similarity index 94% rename from cypress/integration/middleware/rewrites-redirects.spec.ts rename to cypress/integration/middleware/standard.spec.ts index 8174145b57..dd6b38dd97 100644 --- a/cypress/integration/middleware/rewrites-redirects.spec.ts +++ b/cypress/integration/middleware/standard.spec.ts @@ -1,4 +1,4 @@ -describe('Rewrites and Redirects', () => { +describe('Standard middleware', () => { it('rewrites to internal page', () => { // preview mode is off by default cy.visit('/shows/rewriteme') From 1cf3e1fb58456131b9442e37b495722127f7b9e6 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 29 Jul 2022 12:54:02 +0100 Subject: [PATCH 28/31] chore: fix test --- cypress/integration/middleware/enhanced.spec.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cypress/integration/middleware/enhanced.spec.ts b/cypress/integration/middleware/enhanced.spec.ts index 537b6f7431..624a61e574 100644 --- a/cypress/integration/middleware/enhanced.spec.ts +++ b/cypress/integration/middleware/enhanced.spec.ts @@ -1,9 +1,7 @@ describe('Enhanced middleware', () => { it('adds request headers', () => { cy.request('/api/hello').then((response) => { - response.body.json().then((body) => { - expect(body.headers).to.have.property('x-hello', 'world') - }) + expect(response.body).to.have.nested.property('headers.x-hello', 'world') }) }) @@ -14,6 +12,9 @@ describe('Enhanced middleware', () => { }) it('rewrites the response body', () => { + cy.on('uncaught:exception', (err, runnable) => { + console.log(err.message) + }) cy.visit('/static') cy.findByText('This was static but has been transformed in') cy.findByText("This is an ad that isn't shown by default") From 33ade902ff769ef002afc7e48131d2a1515327fa Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 29 Jul 2022 13:26:06 +0100 Subject: [PATCH 29/31] fix: handle other HTTP verbs --- plugin/src/templates/edge/utils.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/plugin/src/templates/edge/utils.ts b/plugin/src/templates/edge/utils.ts index 39f07109d6..49a6d3d659 100644 --- a/plugin/src/templates/edge/utils.ts +++ b/plugin/src/templates/edge/utils.ts @@ -73,6 +73,9 @@ export const buildResponse = async ({ result.response = await result.response.next() } if (isMiddlewareResponse(result.response)) { + if (request.method === 'HEAD' || request.method === 'OPTIONS') { + return response.originResponse + } const { response } = result // 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')) { From 9f4044c9133c5e982401485d8bcaaac6a2c520da Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 29 Jul 2022 14:49:13 +0100 Subject: [PATCH 30/31] feat: add helper methods --- demos/middleware/middleware.ts | 21 +++---------- demos/middleware/pages/static.js | 41 ++++++++++++++++--------- plugin/src/middleware/response.ts | 48 +++++++++++++++++++++++++++++- plugin/src/templates/edge/utils.ts | 2 +- 4 files changed, 79 insertions(+), 33 deletions(-) diff --git a/demos/middleware/middleware.ts b/demos/middleware/middleware.ts index 35d10297a4..2d0b5ea1d5 100644 --- a/demos/middleware/middleware.ts +++ b/demos/middleware/middleware.ts @@ -16,23 +16,10 @@ export async function middleware(req: NextRequest) { const res = await request.next() const message = `This was static but has been transformed in ${req.geo.city}` - // Transform the response page data - res.transformData((data) => { - data.pageProps.message = message - data.pageProps.showAd = true - return data - }) - - // Transform the response HTML - res.rewriteHTML('p[id=message]', { - text(textChunk) { - if (textChunk.lastInTextNode) { - textChunk.replace(message) - } else { - textChunk.remove() - } - }, - }) + // Transform the response HTML and props + res.replaceText('p[id=message]', message) + res.setPageProp('message', message) + res.setPageProp('showAd', true) return res } diff --git a/demos/middleware/pages/static.js b/demos/middleware/pages/static.js index 2345a7b975..0ac0fd6121 100644 --- a/demos/middleware/pages/static.js +++ b/demos/middleware/pages/static.js @@ -1,18 +1,31 @@ -const Page = ({ message, showAd }) => ( -
-

{message}

- {showAd ? ( -
-

This is an ad that isn't shown by default

- -
- ) : ( -

No ads for me

- )} -
-) +import * as React from 'react' -export async function getStaticProps(context) { +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', diff --git a/plugin/src/middleware/response.ts b/plugin/src/middleware/response.ts index a04126d077..04f21be79b 100644 --- a/plugin/src/middleware/response.ts +++ b/plugin/src/middleware/response.ts @@ -3,7 +3,7 @@ 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 +export type NextDataTransform = }>(props: T) => T // A NextReponse that wraps the Netlify origin response // We can't pass it through directly, because Next disallows returning a response body @@ -42,6 +42,52 @@ export class MiddlewareResponse extends NextResponse { 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/utils.ts b/plugin/src/templates/edge/utils.ts index 49a6d3d659..ffe251e55a 100644 --- a/plugin/src/templates/edge/utils.ts +++ b/plugin/src/templates/edge/utils.ts @@ -73,10 +73,10 @@ export const buildResponse = async ({ result.response = await result.response.next() } if (isMiddlewareResponse(result.response)) { + const { response } = result if (request.method === 'HEAD' || request.method === 'OPTIONS') { return response.originResponse } - const { response } = result // 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() From 78c8225e6c26fb29d4f90b52d56ddd174d424e52 Mon Sep 17 00:00:00 2001 From: Matt Kane Date: Fri, 29 Jul 2022 15:16:14 +0100 Subject: [PATCH 31/31] fix: less flaky test --- cypress/integration/middleware/enhanced.spec.ts | 7 ++----- plugin/src/middleware/response.ts | 2 +- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/cypress/integration/middleware/enhanced.spec.ts b/cypress/integration/middleware/enhanced.spec.ts index 624a61e574..d70d4bacd8 100644 --- a/cypress/integration/middleware/enhanced.spec.ts +++ b/cypress/integration/middleware/enhanced.spec.ts @@ -12,12 +12,9 @@ describe('Enhanced middleware', () => { }) it('rewrites the response body', () => { - cy.on('uncaught:exception', (err, runnable) => { - console.log(err.message) - }) cy.visit('/static') - cy.findByText('This was static but has been transformed in') - cy.findByText("This is an ad that isn't shown by default") + 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', () => { diff --git a/plugin/src/middleware/response.ts b/plugin/src/middleware/response.ts index 04f21be79b..ad01e75609 100644 --- a/plugin/src/middleware/response.ts +++ b/plugin/src/middleware/response.ts @@ -5,7 +5,7 @@ import type { ElementHandlers } from './html-rewriter' // eslint-disable-next-line @typescript-eslint/no-explicit-any export type NextDataTransform = }>(props: T) => T -// A NextReponse that wraps the Netlify origin response +// 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[]