├── .gitignore ├── .vscode ├── launch.json └── tasks.json ├── LICENSE ├── NextjsStaticHosting.AspNetCore.sln ├── README.md ├── global.json ├── samples ├── ActualNextjsApp │ ├── ActualNextjsApp.Client │ │ ├── .eslintrc.json │ │ ├── .gitignore │ │ ├── README.md │ │ ├── next.config.js │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── pages │ │ │ ├── _app.tsx │ │ │ ├── _document.tsx │ │ │ ├── index.tsx │ │ │ └── post │ │ │ │ └── [postId].tsx │ │ ├── public │ │ │ └── favicon.ico │ │ ├── styles │ │ │ └── globals.css │ │ └── tsconfig.json │ ├── ActualNextjsApp.Server │ │ ├── ActualNextjsApp.Server.csproj │ │ ├── Controllers │ │ │ └── EchoController.cs │ │ ├── Program.cs │ │ ├── Properties │ │ │ └── launchSettings.json │ │ ├── appsettings.Development.json │ │ ├── appsettings.json │ │ └── wwwroot │ │ │ └── README.md │ └── README.md └── PreBuiltClientDemo │ ├── ClientApp │ ├── README.md │ ├── index.html │ └── post │ │ ├── [...slug].html │ │ ├── [pid].html │ │ └── index.html │ ├── PreBuiltClientDemo.csproj │ ├── Program.cs │ ├── Properties │ └── launchSettings.json │ └── appsettings.json ├── src └── NextjsStaticHosting.AspNetCore │ ├── Internals │ ├── FileProviderFactory.cs │ ├── NextjsEndpointDataSource.cs │ ├── ProxyToDevServerMiddleware.cs │ └── StaticFileOptionsProvider.cs │ ├── InternalsVisibleTo.cs │ ├── LOGO_LICENSE.md │ ├── NextjsStaticHosting.AspNetCore.csproj │ ├── NextjsStaticHostingExtensions.cs │ ├── NextjsStaticHostingOptions.cs │ └── logo.png └── test └── NextjsStaticHosting.AspNetCore.Test ├── Internals └── NextjsEndpointDataSourceTests.cs └── NextjsStaticHosting.AspNetCore.Test.csproj /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | build/ 21 | bld/ 22 | bin/ 23 | Bin/ 24 | obj/ 25 | Obj/ 26 | 27 | # Visual Studio 2015 cache/options directory 28 | .vs/ 29 | /wwwroot/dist/ 30 | 31 | # MSTest test Results 32 | [Tt]est[Rr]esult*/ 33 | [Bb]uild[Ll]og.* 34 | 35 | # NUNIT 36 | *.VisualState.xml 37 | TestResult.xml 38 | 39 | # Build Results of an ATL Project 40 | [Dd]ebugPS/ 41 | [Rr]eleasePS/ 42 | dlldata.c 43 | 44 | *_i.c 45 | *_p.c 46 | *_i.h 47 | *.ilk 48 | *.meta 49 | *.obj 50 | *.pch 51 | *.pdb 52 | *.pgc 53 | *.pgd 54 | *.rsp 55 | *.sbr 56 | *.tlb 57 | *.tli 58 | *.tlh 59 | *.tmp 60 | *.tmp_proj 61 | *.log 62 | *.vspscc 63 | *.vssscc 64 | .builds 65 | *.pidb 66 | *.svclog 67 | *.scc 68 | 69 | # Chutzpah Test files 70 | _Chutzpah* 71 | 72 | # Visual C++ cache files 73 | ipch/ 74 | *.aps 75 | *.ncb 76 | *.opendb 77 | *.opensdf 78 | *.sdf 79 | *.cachefile 80 | 81 | # Visual Studio profiler 82 | *.psess 83 | *.vsp 84 | *.vspx 85 | *.sap 86 | 87 | # TFS 2012 Local Workspace 88 | $tf/ 89 | 90 | # Guidance Automation Toolkit 91 | *.gpState 92 | 93 | # ReSharper is a .NET coding add-in 94 | _ReSharper*/ 95 | *.[Rr]e[Ss]harper 96 | *.DotSettings.user 97 | 98 | # JustCode is a .NET coding add-in 99 | .JustCode 100 | 101 | # TeamCity is a build add-in 102 | _TeamCity* 103 | 104 | # DotCover is a Code Coverage Tool 105 | *.dotCover 106 | 107 | # NCrunch 108 | _NCrunch_* 109 | .*crunch*.local.xml 110 | nCrunchTemp_* 111 | 112 | # MightyMoose 113 | *.mm.* 114 | AutoTest.Net/ 115 | 116 | # Web workbench (sass) 117 | .sass-cache/ 118 | 119 | # Installshield output folder 120 | [Ee]xpress/ 121 | 122 | # DocProject is a documentation generator add-in 123 | DocProject/buildhelp/ 124 | DocProject/Help/*.HxT 125 | DocProject/Help/*.HxC 126 | DocProject/Help/*.hhc 127 | DocProject/Help/*.hhk 128 | DocProject/Help/*.hhp 129 | DocProject/Help/Html2 130 | DocProject/Help/html 131 | 132 | # Click-Once directory 133 | publish/ 134 | 135 | # Publish Web Output 136 | *.[Pp]ublish.xml 137 | *.azurePubxml 138 | # TODO: Comment the next line if you want to checkin your web deploy settings 139 | # but database connection strings (with potential passwords) will be unencrypted 140 | *.pubxml 141 | *.publishproj 142 | 143 | # NuGet Packages 144 | *.nupkg 145 | # The packages folder can be ignored because of Package Restore 146 | **/packages/* 147 | # except build/, which is used as an MSBuild target. 148 | !**/packages/build/ 149 | # Uncomment if necessary however generally it will be regenerated when needed 150 | #!**/packages/repositories.config 151 | 152 | # Microsoft Azure Build Output 153 | csx/ 154 | *.build.csdef 155 | 156 | # Microsoft Azure Emulator 157 | ecf/ 158 | rcf/ 159 | 160 | # Microsoft Azure ApplicationInsights config file 161 | ApplicationInsights.config 162 | 163 | # Windows Store app package directory 164 | AppPackages/ 165 | BundleArtifacts/ 166 | 167 | # Visual Studio cache files 168 | # files ending in .cache can be ignored 169 | *.[Cc]ache 170 | # but keep track of directories ending in .cache 171 | !*.[Cc]ache/ 172 | 173 | # Others 174 | ClientBin/ 175 | ~$* 176 | *~ 177 | *.dbmdl 178 | *.dbproj.schemaview 179 | *.pfx 180 | *.publishsettings 181 | orleans.codegen.cs 182 | 183 | /node_modules 184 | 185 | # RIA/Silverlight projects 186 | Generated_Code/ 187 | 188 | # Backup & report files from converting an old project file 189 | # to a newer Visual Studio version. Backup files are not needed, 190 | # because we have git ;-) 191 | _UpgradeReport_Files/ 192 | Backup*/ 193 | UpgradeLog*.XML 194 | UpgradeLog*.htm 195 | 196 | # SQL Server files 197 | *.mdf 198 | *.ldf 199 | 200 | # Business Intelligence projects 201 | *.rdl.data 202 | *.bim.layout 203 | *.bim_*.settings 204 | 205 | # Microsoft Fakes 206 | FakesAssemblies/ 207 | 208 | # GhostDoc plugin setting file 209 | *.GhostDoc.xml 210 | 211 | # Node.js Tools for Visual Studio 212 | .ntvs_analysis.dat 213 | 214 | # Visual Studio 6 build log 215 | *.plg 216 | 217 | # Visual Studio 6 workspace options file 218 | *.opt 219 | 220 | # Visual Studio LightSwitch build output 221 | **/*.HTMLClient/GeneratedArtifacts 222 | **/*.DesktopClient/GeneratedArtifacts 223 | **/*.DesktopClient/ModelManifest.xml 224 | **/*.Server/GeneratedArtifacts 225 | **/*.Server/ModelManifest.xml 226 | _Pvt_Extensions 227 | 228 | # Paket dependency manager 229 | .paket/paket.exe 230 | 231 | # FAKE - F# Make 232 | .fake/ 233 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "name": "PreBuiltClientDemo", 6 | "type": "coreclr", 7 | "request": "launch", 8 | "preLaunchTask": "build", 9 | // If you have changed target frameworks, make sure to update the program path. 10 | "program": "${workspaceFolder}/samples/PreBuiltClientDemo/bin/Debug/net7.0/PreBuiltClientDemo.dll", 11 | "args": [], 12 | "cwd": "${workspaceFolder}/samples/PreBuiltClientDemo", 13 | "stopAtEntry": false, 14 | // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser 15 | "serverReadyAction": { 16 | "action": "openExternally", 17 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)" 18 | }, 19 | "env": { 20 | "ASPNETCORE_ENVIRONMENT": "Development" 21 | } 22 | }, 23 | { 24 | "name": "ActualNextjsApp.Server", 25 | // Starts ActualNextjsApp.Server, which expects to find the local Next.js dev server running at http://localhost:3000. 26 | // You need to manually start the Next.js dev server for ActualNextJsApp.Client for this to work. For example: 27 | // ``` 28 | // cd samples/ActualNextjsApp/ActualNextjsApp.Client && npm run dev 29 | // ``` 30 | // 31 | // If you don't run the dev server correctly, you may see an error like this when you try to access the sample app in a browser: 32 | // ``` 33 | // [NextjsStaticHosting.AspNetCore] Unable to reach Next.js dev server. Please ensure it is running at http://localhost:3000/. 34 | // ``` 35 | // 36 | "type": "coreclr", 37 | "request": "launch", 38 | "preLaunchTask": "build", 39 | // If you have changed target frameworks, make sure to update the program path. 40 | "program": "${workspaceFolder}/samples/ActualNextjsApp/ActualNextjsApp.Server/bin/Debug/net7.0/ActualNextjsApp.Server.dll", 41 | "args": [], 42 | "cwd": "${workspaceFolder}/samples/ActualNextjsApp/ActualNextjsApp.Server", 43 | "stopAtEntry": false, 44 | // Enable launching a web browser when ASP.NET Core starts. For more information: https://aka.ms/VSCode-CS-LaunchJson-WebBrowser 45 | "serverReadyAction": { 46 | "action": "openExternally", 47 | "pattern": "\\bNow listening on:\\s+(https?://\\S+)" 48 | }, 49 | "env": { 50 | "ASPNETCORE_ENVIRONMENT": "Development" 51 | } 52 | } 53 | ] 54 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "build", 6 | "command": "dotnet", 7 | "type": "process", 8 | "args": [ 9 | "build", 10 | "${workspaceFolder}", 11 | "/property:GenerateFullPaths=true", 12 | "/consoleloggerparameters:NoSummary" 13 | ], 14 | "problemMatcher": "$msCompile" 15 | } 16 | ] 17 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 David Nissimoff 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /NextjsStaticHosting.AspNetCore.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.1.32210.238 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NextjsStaticHosting.AspNetCore", "src\NextjsStaticHosting.AspNetCore\NextjsStaticHosting.AspNetCore.csproj", "{6E9A0B23-AEF8-47AF-9A24-76D8DF30C0E2}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{CCCA0538-4989-4F46-9544-74E75AC3F50C}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{1860F7B0-378B-40E5-92F3-8535F08643B3}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "NextjsStaticHosting.AspNetCore.Test", "test\NextjsStaticHosting.AspNetCore.Test\NextjsStaticHosting.AspNetCore.Test.csproj", "{D3B6E8A4-2533-4DC0-A391-DDB3495B49C4}" 13 | EndProject 14 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{AB8F5296-5E51-482E-9CE9-13384A204D91}" 15 | EndProject 16 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ActualNextjsApp.Server", "samples\ActualNextjsApp\ActualNextjsApp.Server\ActualNextjsApp.Server.csproj", "{AC028F5E-F9F5-43FE-A5AE-E28A7BEB7178}" 17 | EndProject 18 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PreBuiltClientDemo", "samples\PreBuiltClientDemo\PreBuiltClientDemo.csproj", "{389096DC-2E97-479F-A3C3-7523DCB92299}" 19 | EndProject 20 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{5E3EE226-40BE-42B1-A55D-FA35B8AEDBCC}" 21 | ProjectSection(SolutionItems) = preProject 22 | LICENSE = LICENSE 23 | README.md = README.md 24 | EndProjectSection 25 | EndProject 26 | Global 27 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 28 | Debug|Any CPU = Debug|Any CPU 29 | Release|Any CPU = Release|Any CPU 30 | EndGlobalSection 31 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 32 | {6E9A0B23-AEF8-47AF-9A24-76D8DF30C0E2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 33 | {6E9A0B23-AEF8-47AF-9A24-76D8DF30C0E2}.Debug|Any CPU.Build.0 = Debug|Any CPU 34 | {6E9A0B23-AEF8-47AF-9A24-76D8DF30C0E2}.Release|Any CPU.ActiveCfg = Release|Any CPU 35 | {6E9A0B23-AEF8-47AF-9A24-76D8DF30C0E2}.Release|Any CPU.Build.0 = Release|Any CPU 36 | {D3B6E8A4-2533-4DC0-A391-DDB3495B49C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 37 | {D3B6E8A4-2533-4DC0-A391-DDB3495B49C4}.Debug|Any CPU.Build.0 = Debug|Any CPU 38 | {D3B6E8A4-2533-4DC0-A391-DDB3495B49C4}.Release|Any CPU.ActiveCfg = Release|Any CPU 39 | {D3B6E8A4-2533-4DC0-A391-DDB3495B49C4}.Release|Any CPU.Build.0 = Release|Any CPU 40 | {AC028F5E-F9F5-43FE-A5AE-E28A7BEB7178}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 41 | {AC028F5E-F9F5-43FE-A5AE-E28A7BEB7178}.Debug|Any CPU.Build.0 = Debug|Any CPU 42 | {AC028F5E-F9F5-43FE-A5AE-E28A7BEB7178}.Release|Any CPU.ActiveCfg = Release|Any CPU 43 | {AC028F5E-F9F5-43FE-A5AE-E28A7BEB7178}.Release|Any CPU.Build.0 = Release|Any CPU 44 | {389096DC-2E97-479F-A3C3-7523DCB92299}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 45 | {389096DC-2E97-479F-A3C3-7523DCB92299}.Debug|Any CPU.Build.0 = Debug|Any CPU 46 | {389096DC-2E97-479F-A3C3-7523DCB92299}.Release|Any CPU.ActiveCfg = Release|Any CPU 47 | {389096DC-2E97-479F-A3C3-7523DCB92299}.Release|Any CPU.Build.0 = Release|Any CPU 48 | EndGlobalSection 49 | GlobalSection(SolutionProperties) = preSolution 50 | HideSolutionNode = FALSE 51 | EndGlobalSection 52 | GlobalSection(NestedProjects) = preSolution 53 | {6E9A0B23-AEF8-47AF-9A24-76D8DF30C0E2} = {CCCA0538-4989-4F46-9544-74E75AC3F50C} 54 | {D3B6E8A4-2533-4DC0-A391-DDB3495B49C4} = {1860F7B0-378B-40E5-92F3-8535F08643B3} 55 | {AC028F5E-F9F5-43FE-A5AE-E28A7BEB7178} = {AB8F5296-5E51-482E-9CE9-13384A204D91} 56 | {389096DC-2E97-479F-A3C3-7523DCB92299} = {AB8F5296-5E51-482E-9CE9-13384A204D91} 57 | EndGlobalSection 58 | GlobalSection(ExtensibilityGlobals) = postSolution 59 | SolutionGuid = {493F75AA-94E3-4700-9FD9-FF3C49395A00} 60 | EndGlobalSection 61 | EndGlobal 62 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![NuGet Gallery | NextjsStaticHosting.AspNetCore](https://img.shields.io/nuget/v/NextjsStaticHosting.AspNetCore?style=plastic)](https://www.nuget.org/packages/NextjsStaticHosting.AspNetCore) 2 | 3 | Host a statically-exported [Next.js](https://nextjs.org/) client-side application on ASP .NET Core 4 | with full support for SSG apps, including Dynamic Routes 5 | (see [Next.js: Server-side Rendering vs. Static Generation](https://vercel.com/blog/nextjs-server-side-rendering-vs-static-generation)). 6 | 7 | This is an ideal and scalable option for large scale production deployments of Next.js apps without requiring Node.js on the server. 8 | See the [blog post](https://medium.com/@david.nissimoff/next-js-meets-asp-net-core-a-story-of-performance-and-love-at-long-tail-41cf9231b2de) 9 | 10 | # Usage 11 | 12 | ## Option 1: Running the sample with your own Next.js app 13 | 14 | 1. Export your Next.js app using `npx next export` 15 | 2. Copy the generated outputs from `out` to `.\samples\PreBuiltClientDemo\ClientApp` 16 | 3. Run `samples\PreBuiltClientDemo` and see it working at `https://locahost:5001`. 17 | 18 | 19 | ## Option 2: Adding `NextjsStaticHosting` to an existing ASP .NET Core project (minimal API's) 20 | 21 | Modify your `Program.cs` as follows: 22 | 23 | ```diff 24 | +using NextjsStaticHosting.AspNetCore; 25 | 26 | var builder = WebApplication.CreateBuilder(args); 27 | 28 | +builder.Services.Configure(builder.Configuration.GetSection("NextjsStaticHosting")); 29 | +builder.Services.AddNextjsStaticHosting(); 30 | 31 | var app = builder.Build(); 32 | app.UseRouting(); 33 | +app.MapNextjsStaticHtmls(); 34 | +app.UseNextjsStaticHosting(); 35 | 36 | app.Run(); 37 | ``` 38 | 39 | 40 | ## Option 3: Adding `NextjsStaticHosting` to an existing ASP .NET Core project (traditional startup style API's) 41 | 42 | Add the following to your `Startup.cs`: 43 | 44 | ```diff 45 | public void ConfigureServices(IServiceCollection services) 46 | { 47 | + services.Configure(builder.Configuration.GetSection("NextjsStaticHosting")); 48 | + services.AddNextjsStaticHosting(); 49 | } 50 | 51 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 52 | { 53 | app.UseRouting(); 54 | + app.MapNextjsStaticHtmls(); 55 | + app.UseNextjsStaticHosting(); 56 | } 57 | ``` 58 | 59 | # Why ASP .NET Core 60 | 61 | Next.js applications are usually hosted on a Node.js server which, among other things, takes care of routing concerns and serves the appropriate files for each incoming request. While this is a fine choice in many scenarios, there are use cases where it may be desirable to use a different stack such as ASP .NET Core (e.g. for scalability concerns or because an application's backend may already use ASP .NET Core, and setting up a separate infra to host the client app may be challenging and / or costly). 62 | 63 | One may think that pure static hosting is possible thanks to Next.js' support for [Static HTML Export](https://nextjs.org/docs/advanced-features/static-html-export), and hence no additional server side support would be necessary. And one would be *almost* correct. While that statement bears some truth, and Static HTML Export is indeed a powerful feature of Next.js that this library relies on, it alone does not solve all problems. 64 | 65 | Critically, Next.js static export does **not** produce SPA's (Single Page Applications). Next.js goes through great lengths to ensure an optimal user experience on first-page-loads as well as for client-driven navigations without reloading the page, and its design **requires** that precisely the right page be served to the client during initial load of a page. This is in fact one of its main advantages compared to other stacks. 66 | 67 | For example, imagine a Next.js application consisting of the following pages: 68 | 69 | * `/pages/index.js` 70 | * `/pages/post/[pid].js` 71 | 72 | When statically exported to HTML using `npx next export`, the exported output will contain the following entry-point HTML files: 73 | 74 | * `/out/index.html` 75 | * `/out/post/[pid].html` 76 | 77 | When a browser issues a request for `/`, the server is expected to return the contents of `/out/index.html`. Similarly, a request for `/post/123` is supposed to return the contents of `/out/post/[pid].html`. As long as the appropriate initial HTML is served for the incoming request paths, Next.js takes care of the rest, and will rehydrate the page on the client-side providing full React and interaction capabilities. 78 | 79 | **However**, if the wrong page is served (e.g., if `/out/index.html` were served on a request for `/post/123`), rehydration won't work (by design!). The client will render the contents of `/pages/index.js` page even though the URL bar will say `/post/123`, and it will NOT rehydrate the contents that were expected for `/post/123` -- code running in the browser at that time in fact would not even know that it was supposed to be showing the contents of a different page. 80 | 81 | The purpose of this library is to add the necessary routing machinery so that ASP .NET Core serves the correct Next.js statically-generated HTML files according to user requests. **It leverages ASP .NET Core Endpoint Routing for unparalleled performance, and avoids any superfluous computations on the hot path**. 82 | 83 | By design, **this library does NOT support dynamic SSR (Server Side Rendering)**. Applications requiring dynamic Server Side Rendering likely would prefer or need to use Node.js on the server anyway. 84 | 85 | 86 | ## Scalability and Security considerations 87 | 88 | Because no custom code is executed to serve static files (other than ASP .NET Core's built-in Endpoint Routing and Static Files), this is just about the most efficient way to host a statically generated Next.js application. 89 | 90 | Additionally, because we deliberately do not support dynamic SSR, you do not need to worry about running Node.js or JavaScript in your production servers. 91 | 92 | 93 | ## Limitations 94 | 95 | By design, this library does not support Server Side Rendering, yet it offers full support for SSG (Static Site Generation). This offers many of the advantages of SSR, including fast initial load and SEO-friendliness. 96 | 97 | A simple way to reason about this is as follows: 98 | 99 | > **If your Next.js application is eligible for [Static HTML Export](https://nextjs.org/docs/advanced-features/static-html-export), then it can be hosted on ASP .NET Core with this library.** 100 | 101 | -------------------------------------------------------------------------------- /global.json: -------------------------------------------------------------------------------- 1 | { 2 | "sdk": { 3 | "version": "6.0", 4 | "rollForward": "latestPatch" 5 | } 6 | } -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Client/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | /build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | .pnpm-debug.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Client/README.md: -------------------------------------------------------------------------------- 1 | This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). 2 | 3 | # Using with NextjsStaticHosting.AspNetCore 4 | 5 | ## 1: Install dependencies 6 | 7 | ```bash 8 | npm install 9 | ``` 10 | 11 | ## 2: Run or deploy 12 | 13 | ### 2.1: Local development 14 | 15 | 1. Start the Next.js dev server: 16 | 17 | ```bash 18 | npm run dev 19 | ``` 20 | 21 | 2. Launch the ASP .NET Core project in development mode 22 | 23 | Run ActualNextjsApp.Server in Visual Studio 24 | 25 | ### 2.2: Deploy to production 26 | 27 | 1. Build and export the Next.js app to generate static files: 28 | 29 | ```bash 30 | npm run build-export 31 | ``` 32 | 33 | 2. Publish the ASP .NET Core project 34 | 35 | When you publish ActualNextjsApp.Server, it will include the outputs generated at `./out` 36 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Client/next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = { 3 | reactStrictMode: true, 4 | } 5 | 6 | module.exports = nextConfig 7 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trivial-app", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "build-export": "next build && next export", 9 | "start": "next start", 10 | "lint": "next lint" 11 | }, 12 | "dependencies": { 13 | "@next/font": "13.1.6", 14 | "@types/node": "18.13.0", 15 | "@types/react": "18.0.28", 16 | "@types/react-dom": "18.0.10", 17 | "eslint": "8.34.0", 18 | "eslint-config-next": "13.1.6", 19 | "next": "13.1.6", 20 | "react": "18.2.0", 21 | "react-dom": "18.2.0", 22 | "typescript": "4.9.5" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Client/pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import '@/styles/globals.css' 2 | import type { AppProps } from 'next/app' 3 | 4 | export default function App({ Component, pageProps }: AppProps) { 5 | return 6 | } 7 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Client/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document' 2 | 3 | export default function Document() { 4 | return ( 5 | 6 | 7 | 8 |
9 | 10 | 11 | 12 | ) 13 | } 14 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Client/pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import type { NextPage } from 'next' 3 | import Link from 'next/link' 4 | 5 | const Home: NextPage = () => { 6 | const [counter, setCounter] = React.useState(0); 7 | React.useEffect(() => { 8 | const c = setInterval(() => { 9 | setCounter(counter => counter + 1); 10 | }, 500); 11 | return () => clearInterval(c); 12 | }, []); 13 | 14 | return ( 15 |
16 |

Home

17 | 18 | This is a fully-functional Next.js app, check out this counter: {counter} 19 | 20 |

Other pages:

21 |
    22 |
  • Post 1
  • 23 |
  • Post 2
  • 24 |
25 |
26 | ) 27 | } 28 | 29 | export default Home; 30 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Client/pages/post/[postId].tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import type { NextPage } from 'next' 3 | import Link from 'next/link' 4 | import { useRouter } from 'next/router' 5 | 6 | const Home: NextPage = () => { 7 | 8 | const [postId, setPostId] = React.useState(); 9 | 10 | const router = useRouter(); 11 | React.useEffect(() => { 12 | setPostId(router.query.postId as string | undefined); 13 | }, [router]); 14 | 15 | return ( 16 |
17 |

This is post {postId}

18 | 19 |

Other pages:

20 |
    21 |
  • Home
  • 22 |
23 |
24 | ) 25 | } 26 | 27 | export default Home; 28 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidnx/NextjsStaticHosting-AspNetCore/2274bebbbfa801a627bd78ccbf970f20e57beaa0/samples/ActualNextjsApp/ActualNextjsApp.Client/public/favicon.ico -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Client/styles/globals.css: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | padding: 0; 4 | margin: 0; 5 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 6 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 7 | } 8 | 9 | .container { 10 | padding: 16px; 11 | } 12 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": true, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "esModuleInterop": true, 11 | "module": "esnext", 12 | "moduleResolution": "node", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "jsx": "preserve", 16 | "incremental": true, 17 | "baseUrl": ".", 18 | "paths": { 19 | "@/*": ["./*"] 20 | } 21 | }, 22 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 23 | "exclude": ["node_modules"] 24 | } 25 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Server/ActualNextjsApp.Server.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 14 | $(MSBuildProjectDirectory)\..\ActualNextjsApp.Client\out 15 | 16 | 18 | clientdist 19 | 20 | 21 | <_NextjsCustomFiles Include="$(NextJsCompiledOutputPath)\**" /> 22 | 23 | $(NextJsOutputPublishRelativePath)\%(RecursiveDir)%(Filename)%(Extension) 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Server/Controllers/EchoController.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Mvc; 2 | 3 | namespace ActualNextjsApp.Server.Controllers 4 | { 5 | public class EchoController : Controller 6 | { 7 | [Route("/api/echo")] 8 | public string Echo() 9 | { 10 | return "Hello world"; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Server/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using NextjsStaticHosting.AspNetCore; 4 | 5 | var builder = WebApplication.CreateBuilder(args); 6 | builder.Services.AddControllers(); 7 | 8 | // Step 1: Add Next.js hosting support 9 | builder.Services.Configure(builder.Configuration.GetSection("NextjsStaticHosting")); 10 | builder.Services.AddNextjsStaticHosting(); 11 | 12 | var app = builder.Build(); 13 | app.UseRouting(); 14 | 15 | // Not necessary for the sample, added to demonstrate that controllers still work as usual 16 | app.MapControllerRoute( 17 | name: "default", 18 | pattern: "{controller}/{action=Index}/{id?}"); 19 | 20 | // Step 2: Register dynamic endpoints to serve the correct HTML files at the right request paths. 21 | // Endpoints are created dynamically based on HTML files found under the specified RootPath during startup. 22 | // Endpoints are currently NOT refreshed if the files later change on disk. 23 | app.MapNextjsStaticHtmls(); 24 | 25 | // Step 3: Serve other required files (e.g. js, css files in the exported next.js app). 26 | app.UseNextjsStaticHosting(); 27 | 28 | app.Run(); 29 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Server/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "ActualNextjsApp.Server": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "applicationUrl": "https://localhost:5003;http://localhost:5002", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development" 9 | }, 10 | "hotReloadEnabled": false 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Server/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information", 7 | "Yarp.ReverseProxy.Forwarder": "Warning" 8 | } 9 | }, 10 | "NextjsStaticHosting": { 11 | "DevServer": "http://localhost:3000/", 12 | "ProxyToDevServer": true 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Server/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "AllowedHosts": "*", 10 | "NextjsStaticHosting": { 11 | // Must match where the Next.js app outputs are published, relative to the app's ContentRootPath. 12 | // See property `NextJsOutputPublishRelativePath` in the csproj. 13 | "RootPath": "clientdist" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/ActualNextjsApp.Server/wwwroot/README.md: -------------------------------------------------------------------------------- 1 | # About this directory 2 | 3 | Static files will be served from here as well as from the published Next.js app outputs. 4 | -------------------------------------------------------------------------------- /samples/ActualNextjsApp/README.md: -------------------------------------------------------------------------------- 1 | # ActualNextjsApp sample 2 | 3 | This sample illustrates a few things that help run a Next.js application reliably and at scale with an ASP .NET Core server. 4 | 5 | ## 1. Folder structure 6 | 7 | Notice the folders `ActualNextjsApp.Client` and `ActualNextjsApp.Server`. 8 | 9 | Your Next.js application should be placed inside of `./ActualNextjsApp.Client`, such that when you run `npx next export` on it, 10 | Next.js will produce the outputs at `./ActualNextjsApp.Client/out/`. 11 | This is important because the corresponding server app at `ActualNextjsApp.Server` 12 | is configured to look for the Next.js app outputs at precisely that location. 13 | See property `NextJsCompiledOutputPath` in [ActualNextjsApp.Server.csproj](./ActualNextjsApp.Server/ActualNextjsApp.Server.csproj) 14 | and `NextjsStaticHosting:RootPath` in [appsettings.json](./ActualNextjsApp.Server/appsettings.json) 15 | 16 | ## Dev experience 17 | 18 | When developing locally, you need to do two things: 19 | 20 | 1. Run your Next.js app in development, which gives you full Hot-Module-Reload support with NextJsStaticHosting.AspNetCore. 21 | Example: `cd ./ActualNextjsApp.Client && npm run dev` 22 | 23 | 2. Run the server ASP .NET Core app in development mode (i.e. `ASPNETCORE_ENVIRONMENT=Development`). 24 | This is done automatically for you when running from Visual Studio. 25 | Notice that when the server app runs in development mode, the `appsettings.Development.json` file 26 | instructs `NextJsStaticHosting.AspNetCore` to proxy appropriate requests to your local Next.js dev server 27 | via configuration keys `NextjsStaticHosting:DevServer` and `NextjsStaticHosting:ProxyToDevServer` 28 | 29 | ## Production experience 30 | 31 | When deploying to production, `ActualNextjsApp.Server` is configured to copy the compiled Next.js app outputs to the published outputs. 32 | When running in production, configuration option `NextjsStaticHosting:ProxyToDevServer` is `false` (default), therefore no proxying 33 | will be performed, and the right files will be served directly from disk. 34 | 35 | This means you WILL NOT run Node.js in production. All Next.js compiled outputs will be served as static files 36 | but with smart routing capabilities to enable full fidelity to an actual Next.js server running in SSG-only mode. 37 | 38 | **Remember to build and export the client app before publishing `ActualNextjsApp.Server`!** This can be automated with MSBuild in the csproj if desired, but isn't shown in this demo. 39 | -------------------------------------------------------------------------------- /samples/PreBuiltClientDemo/ClientApp/README.md: -------------------------------------------------------------------------------- 1 | # About this directory 2 | 3 | Replace the contents of this folder with the outputs of your exported Next.js application. 4 | 5 | In your Next.js application folder, run `npx next export`, then copy the outputs from the produced `out` folder to here. 6 | -------------------------------------------------------------------------------- /samples/PreBuiltClientDemo/ClientApp/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Index 5 | 6 | 7 |

This is the /index page

8 |

It should be returned for requests issued to "/".

9 |

Other places you can go:

10 | 13 | 14 | -------------------------------------------------------------------------------- /samples/PreBuiltClientDemo/ClientApp/post/[...slug].html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Some post with catch-all slug 5 | 6 | 7 |

This is the /post/[...slug] page

8 |

9 | NOTE: This demo doesn't run any Javscript, so you really should see hardcoded placeholders like [...slug]. 10 |
11 | If this were an actual Next.js app, you could use router.query to render custom content as appropriate. 12 |

13 |

It should be returned for requests issued to "/post/{*slug}". For example:

14 |
    15 |
  • "/post/123/a"
  • 16 |
  • "/post/a/b/c"
  • 17 |
18 |

It should NOT be returned for requests such as "/post/123", as that should instead serve page "/post/[pid].html".

19 |

Other places you can go:

20 | 24 | 25 | -------------------------------------------------------------------------------- /samples/PreBuiltClientDemo/ClientApp/post/[pid].html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Some post 5 | 6 | 7 |

This is the /post/[pid] page

8 |

9 | NOTE: This demo doesn't run any Javscript, so you really should see hardcoded placeholders like [pid]. 10 |
11 | If this were an actual Next.js app, you could use router.query to render custom content as appropriate. 12 |

13 |

It should be returned for requests issued to "/post/{pid}". For example:

14 |
    15 |
  • "/post/123"
  • 16 |
  • "/post/abc"
  • 17 |
18 |

Other places you can go:

19 | 23 | 24 | -------------------------------------------------------------------------------- /samples/PreBuiltClientDemo/ClientApp/post/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Posts home 5 | 6 | 7 |

This is the /post/index page

8 |

It should be returned for requests issued to "/post".

9 |

Other places you can go:

10 | 16 | 17 | -------------------------------------------------------------------------------- /samples/PreBuiltClientDemo/PreBuiltClientDemo.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /samples/PreBuiltClientDemo/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Builder; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using NextjsStaticHosting.AspNetCore; 4 | 5 | var builder = WebApplication.CreateBuilder(args); 6 | 7 | // Step 1: Add Next.js hosting support 8 | builder.Services.Configure(builder.Configuration.GetSection("NextjsStaticHosting")); 9 | builder.Services.AddNextjsStaticHosting(); 10 | 11 | var app = builder.Build(); 12 | app.UseRouting(); 13 | 14 | // Step 2: Register dynamic endpoints to serve the correct HTML files at the right request paths. 15 | app.MapNextjsStaticHtmls(); 16 | 17 | // Step 3: Serve other required files (e.g. js, css files in the exported next.js app). 18 | app.UseNextjsStaticHosting(); 19 | 20 | app.Run(); 21 | -------------------------------------------------------------------------------- /samples/PreBuiltClientDemo/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "ActualNextjsApp.Server": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "applicationUrl": "https://localhost:5001;http://localhost:5000", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development" 9 | } 10 | } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /samples/PreBuiltClientDemo/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft": "Warning", 6 | "Microsoft.Hosting.Lifetime": "Information" 7 | } 8 | }, 9 | "AllowedHosts": "*", 10 | "NextjsStaticHosting": { 11 | // Must match where the Next.js app outputs are published, relative to the app's ContentRootPath. 12 | "RootPath": "ClientApp" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/NextjsStaticHosting.AspNetCore/Internals/FileProviderFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.FileProviders; 2 | 3 | namespace NextjsStaticHosting.AspNetCore.Internals 4 | { 5 | internal class FileProviderFactory 6 | { 7 | public virtual IFileProvider CreateFileProvider(string physicalRoot) => new PhysicalFileProvider(physicalRoot); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/NextjsStaticHosting.AspNetCore/Internals/NextjsEndpointDataSource.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text.RegularExpressions; 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.AspNetCore.Routing; 7 | using Microsoft.AspNetCore.Routing.Patterns; 8 | using Microsoft.Extensions.FileProviders; 9 | using Microsoft.Extensions.Primitives; 10 | 11 | namespace NextjsStaticHosting.AspNetCore.Internals 12 | { 13 | internal class NextjsEndpointDataSource : EndpointDataSource 14 | { 15 | /// 16 | /// Use zero by default so that any conflicts with other default-order ASP .NET Core routes are caught during startup. 17 | /// 18 | private const int DefaultEndpointOrder = 0; 19 | 20 | /// 21 | /// Supports dyamic page segments of the form "[param]" and "[...param]". 22 | /// This does not currently work with dynamic segments of the form "[[...slug]]" 23 | /// Optional catch all routes. 24 | /// 25 | private static readonly Regex slugRegex = new Regex(@"^\[([^\[\]]+?)\]$"); 26 | 27 | private readonly IEndpointRouteBuilder endpointRouteBuilder; 28 | private readonly StaticFileOptionsProvider staticFileOptionsProvider; 29 | private readonly List> conventions; 30 | private IReadOnlyList endpoints; 31 | 32 | internal NextjsEndpointDataSource(IEndpointRouteBuilder endpointRouteBuilder, StaticFileOptionsProvider staticFileOptionsProvider) 33 | { 34 | this.endpointRouteBuilder = endpointRouteBuilder ?? throw new ArgumentNullException(nameof(endpointRouteBuilder)); 35 | this.staticFileOptionsProvider = staticFileOptionsProvider ?? throw new ArgumentNullException(nameof(staticFileOptionsProvider)); 36 | 37 | this.conventions = new List>(); 38 | this.DefaultBuilder = new ConventionBuilder(this.conventions); 39 | } 40 | 41 | /// 42 | /// Gets a used to signal invalidation of cached 43 | /// instances. 44 | /// 45 | /// The . 46 | public override IChangeToken GetChangeToken() => NullChangeToken.Singleton; 47 | 48 | /// 49 | /// Returns a read-only collection of instances. 50 | /// 51 | public override IReadOnlyList Endpoints 52 | { 53 | get 54 | { 55 | this.EnsureInitialized(); 56 | return this.endpoints; 57 | } 58 | } 59 | 60 | public IEndpointConventionBuilder DefaultBuilder { get; } 61 | 62 | private void EnsureInitialized() 63 | { 64 | if (this.endpoints == null) 65 | { 66 | this.endpoints = this.Update(); 67 | } 68 | } 69 | 70 | private IReadOnlyList Update() 71 | { 72 | const string HtmlExtension = ".html"; 73 | const string CatchAllSlugPrefix = "..."; 74 | 75 | var staticFileOptions = this.staticFileOptionsProvider.StaticFileOptions; 76 | var requestDelegate = CreateRequestDelegate(this.endpointRouteBuilder, staticFileOptions); 77 | var endpoints = new List(); 78 | foreach (var filePath in TraverseFiles(staticFileOptions.FileProvider)) 79 | { 80 | if (!filePath.EndsWith(HtmlExtension)) 81 | { 82 | continue; 83 | } 84 | 85 | var fileWithoutHtml = filePath.Substring(0, filePath.Length - HtmlExtension.Length); 86 | var patternSegments = new List(); 87 | var segments = fileWithoutHtml.Split('/'); 88 | 89 | // NOTE: Start at 1 because paths here always have a leading slash 90 | for (int i = 1; i < segments.Length; i++) 91 | { 92 | var segment = segments[i]; 93 | if (i == segments.Length - 1 && segment == "index") 94 | { 95 | // Skip `index` segment, match whatever we got so far. 96 | // This is so that e.g. file `/a/b/index.html` is served at path `/a/b`, as desired. 97 | 98 | // TODO: Should we also serve the same file at `/a/b/index`? Note that `/a/b/index.html` will already work 99 | // via the UseStaticFiles middleware added by `NextjsStaticHostingExtensions.UseNextjsStaticHosting`. 100 | break; 101 | } 102 | 103 | var match = slugRegex.Match(segment); 104 | if (match.Success) 105 | { 106 | string slugName = match.Groups[1].Value; 107 | if (slugName.StartsWith(CatchAllSlugPrefix)) 108 | { 109 | // Catch all route -- see: https://nextjs.org/docs/routing/dynamic-routes#catch-all-routes 110 | var parameterName = slugName.Substring(CatchAllSlugPrefix.Length); 111 | patternSegments.Add( 112 | RoutePatternFactory.Segment( 113 | RoutePatternFactory.ParameterPart(parameterName, null, RoutePatternParameterKind.CatchAll))); 114 | } 115 | else 116 | { 117 | // Dynamic route -- see: https://nextjs.org/docs/routing/dynamic-routes 118 | patternSegments.Add( 119 | RoutePatternFactory.Segment( 120 | RoutePatternFactory.ParameterPart(slugName))); 121 | } 122 | } 123 | else 124 | { 125 | // Literal match 126 | patternSegments.Add( 127 | RoutePatternFactory.Segment( 128 | RoutePatternFactory.LiteralPart(segment))); 129 | } 130 | } 131 | var endpointBuilder = new RouteEndpointBuilder(requestDelegate, RoutePatternFactory.Pattern(patternSegments), order: DefaultEndpointOrder); 132 | 133 | endpointBuilder.Metadata.Add(new StaticFileEndpointMetadata(filePath)); 134 | endpointBuilder.DisplayName = $"Next.js {filePath}"; 135 | foreach (var convention in this.conventions) 136 | { 137 | convention(endpointBuilder); 138 | } 139 | 140 | var endpoint = endpointBuilder.Build(); 141 | endpoints.Add(endpoint); 142 | } 143 | 144 | return endpoints; 145 | } 146 | 147 | private static IEnumerable TraverseFiles(IFileProvider fileProvider) 148 | { 149 | _ = fileProvider ?? throw new ArgumentNullException(nameof(fileProvider)); 150 | 151 | var outstandingPaths = new Stack(); 152 | outstandingPaths.Push(""); 153 | 154 | while (outstandingPaths.Count > 0) 155 | { 156 | var path = outstandingPaths.Pop(); 157 | foreach (var entry in fileProvider.GetDirectoryContents(path)) 158 | { 159 | var entryPath = path + "/" + entry.Name; 160 | if (entry.IsDirectory) 161 | { 162 | outstandingPaths.Push(entryPath); 163 | } 164 | else 165 | { 166 | yield return entryPath; 167 | } 168 | } 169 | } 170 | } 171 | 172 | private static RequestDelegate CreateRequestDelegate(IEndpointRouteBuilder endpoints, StaticFileOptions options) 173 | { 174 | var app = endpoints.CreateApplicationBuilder(); 175 | app.Use(next => context => 176 | { 177 | var endpoint = context.GetEndpoint(); 178 | var metadata = endpoint?.Metadata.GetMetadata(); 179 | if (metadata == null) 180 | { 181 | throw new InvalidOperationException("Endpoint is missing metadata"); 182 | } 183 | 184 | context.Request.Path = metadata.Path; 185 | 186 | // Set endpoint to null so the static files middleware will handle the request. 187 | context.SetEndpoint(null); 188 | 189 | return next(context); 190 | }); 191 | 192 | app.UseStaticFiles(options); 193 | 194 | return app.Build(); 195 | } 196 | 197 | internal sealed class StaticFileEndpointMetadata 198 | { 199 | public StaticFileEndpointMetadata(string path) 200 | { 201 | this.Path = path ?? throw new ArgumentNullException(nameof(path)); 202 | } 203 | 204 | public string Path { get; } 205 | } 206 | 207 | private class ConventionBuilder : IEndpointConventionBuilder 208 | { 209 | private readonly List> conventions; 210 | 211 | public ConventionBuilder(List> conventions) 212 | { 213 | this.conventions = conventions; 214 | } 215 | 216 | public void Add(Action convention) 217 | { 218 | this.conventions.Add(convention); 219 | } 220 | } 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /src/NextjsStaticHosting.AspNetCore/Internals/ProxyToDevServerMiddleware.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net; 3 | using System.Net.Http; 4 | using System.Threading.Tasks; 5 | using Microsoft.AspNetCore.Http; 6 | using Microsoft.AspNetCore.Http.Features; 7 | using Microsoft.Extensions.Logging; 8 | using Microsoft.Extensions.Options; 9 | using Yarp.ReverseProxy.Forwarder; 10 | 11 | namespace NextjsStaticHosting.AspNetCore.Internals 12 | { 13 | internal class ProxyToDevServerMiddleware 14 | { 15 | private readonly NextjsStaticHostingOptions options; 16 | private readonly IHttpForwarder yarpForwarder; 17 | private readonly RequestDelegate next; 18 | private readonly ILogger logger; 19 | private readonly HttpMessageInvoker httpClient; 20 | 21 | public ProxyToDevServerMiddleware(IOptions options, IHttpForwarder yarpForwarder, RequestDelegate next, ILogger logger) 22 | { 23 | this.options = options?.Value ?? throw new ArgumentNullException(nameof(options)); 24 | this.yarpForwarder = yarpForwarder ?? throw new ArgumentNullException(nameof(yarpForwarder)); 25 | this.next = next ?? throw new ArgumentNullException(nameof(next)); 26 | this.logger = logger ?? throw new ArgumentNullException(nameof(logger)); 27 | 28 | if (!this.options.ProxyToDevServer || 29 | string.IsNullOrEmpty(this.options.DevServer)) 30 | { 31 | throw new InvalidOperationException($"{nameof(ProxyToDevServerMiddleware)} should only be added when {nameof(options)}.{nameof(NextjsStaticHostingOptions.ProxyToDevServer)} is set and a valid {nameof(options)}.{nameof(NextjsStaticHostingOptions.DevServer)} is provided. This is a coding defect."); 32 | } 33 | 34 | this.httpClient = new HttpMessageInvoker(new HttpClientHandler 35 | { 36 | AllowAutoRedirect = false, 37 | AutomaticDecompression = DecompressionMethods.None, 38 | UseCookies = false, 39 | UseProxy = false 40 | }); 41 | } 42 | 43 | public async Task InvokeAsync(HttpContext context) 44 | { 45 | var endpoint = context.GetEndpoint(); 46 | if (endpoint != null) 47 | { 48 | // This request will be handled by someone else already, skip proxying to the dev Next.js server... 49 | // Example scenario where we encounter this: a controller in the ASP .NET Core ap already claimed this route -- it should take precedence 50 | await this.next(context); 51 | return; 52 | } 53 | 54 | #if NET6_0_OR_GREATER 55 | // This can be removed once we upgrade to YARP 2.0 (issue: https://github.com/microsoft/reverse-proxy/issues/1375) 56 | // See: https://github.com/microsoft/reverse-proxy/issues/1375#issuecomment-1366099983 57 | if (context.Request.Method == HttpMethods.Connect && context.Request.Protocol != HttpProtocol.Http11) 58 | { 59 | var resetFeature = context.Features.Get(); 60 | if (resetFeature != null) 61 | { 62 | // See: https://www.rfc-editor.org/rfc/rfc7540#section-7 63 | const int HTTP_1_1_REQUIRED = 0xd; 64 | resetFeature.Reset(HTTP_1_1_REQUIRED); 65 | return; 66 | } 67 | } 68 | #endif 69 | 70 | var error = await this.yarpForwarder.SendAsync(context, this.options.DevServer, this.httpClient, new ForwarderRequestConfig { ActivityTimeout = TimeSpan.FromMinutes(5) }); 71 | switch (error) 72 | { 73 | case ForwarderError.None: 74 | case ForwarderError.RequestCanceled: 75 | case ForwarderError.RequestBodyCanceled: 76 | case ForwarderError.ResponseBodyCanceled: 77 | case ForwarderError.UpgradeRequestCanceled: 78 | case ForwarderError.UpgradeResponseCanceled: 79 | // Success, or deliberate client cancellation -- in any case, not our fault, so move on... 80 | break; 81 | default: 82 | // An actual error... 83 | { 84 | string message = 85 | $"[NextjsStaticHosting.AspNetCore] Unable to reach Next.js dev server. Please ensure it is running at {this.options.DevServer}.{Environment.NewLine}" + 86 | $"If you are running in production and did not intend to proxy to a Next.js dev server, please ensure {nameof(NextjsStaticHostingOptions)}.{nameof(NextjsStaticHostingOptions.ProxyToDevServer)} is false.{Environment.NewLine}" + 87 | $"YARP error: {error}"; 88 | this.logger.LogError(message); 89 | if (!context.Response.HasStarted) 90 | { 91 | context.Response.ContentType = "text/plain"; 92 | await context.Response.WriteAsync(message); 93 | } 94 | } 95 | break; 96 | } 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/NextjsStaticHosting.AspNetCore/Internals/StaticFileOptionsProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using Microsoft.AspNetCore.Builder; 4 | using Microsoft.AspNetCore.Hosting; 5 | using Microsoft.Extensions.FileProviders; 6 | using Microsoft.Extensions.Options; 7 | 8 | namespace NextjsStaticHosting.AspNetCore.Internals 9 | { 10 | /// 11 | /// Caches an instance of 12 | /// so that we can re-use the same 13 | /// in and 14 | /// . 15 | /// 16 | internal class StaticFileOptionsProvider 17 | { 18 | public StaticFileOptionsProvider(IWebHostEnvironment env, FileProviderFactory fileProviderFavtory, IOptions options) 19 | { 20 | _ = env ?? throw new ArgumentNullException(nameof(env)); 21 | _ = fileProviderFavtory ?? throw new ArgumentNullException(nameof(fileProviderFavtory)); 22 | var o = options?.Value ?? throw new ArgumentNullException(nameof(options)); 23 | 24 | if (string.IsNullOrEmpty(o.RootPath)) 25 | { 26 | throw new InvalidOperationException($"{nameof(NextjsStaticHostingOptions)}.{nameof(NextjsStaticHostingOptions.RootPath)} must be specified."); 27 | } 28 | 29 | string physicalRoot = Path.Combine(env.ContentRootPath, o.RootPath); 30 | var fileProvider = fileProviderFavtory.CreateFileProvider(physicalRoot); 31 | this.StaticFileOptions = new StaticFileOptions 32 | { 33 | FileProvider = fileProvider, 34 | }; 35 | } 36 | 37 | public StaticFileOptions StaticFileOptions { get; } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/NextjsStaticHosting.AspNetCore/InternalsVisibleTo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | [assembly: InternalsVisibleTo("NextjsStaticHosting.AspNetCore.Test")] 4 | [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] 5 | -------------------------------------------------------------------------------- /src/NextjsStaticHosting.AspNetCore/LOGO_LICENSE.md: -------------------------------------------------------------------------------- 1 | Logo file .\logo.png was remixed from https://en.wikipedia.org/wiki/File:Nextjs-logo.svg. 2 | 3 | This file is licensed under the Creative Commons Attribution-Share Alike 4.0 International license. 4 | You are free: 5 | * to share – to copy, distribute and transmit the work 6 | * to remix – to adapt the work 7 | Under the following conditions: 8 | * attribution – You must give appropriate credit, provide a link to the license, and indicate if changes were made. You may do so in any reasonable manner, but not in any way that suggests the licensor endorses you or your use. 9 | * share alike – If you remix, transform, or build upon the material, you must distribute your contributions under the same or compatible license as the original. 10 | 11 | Links: 12 | * https://en.wikipedia.org/wiki/en:Creative_Commons 13 | * https://creativecommons.org/licenses/by-sa/4.0/deed.en 14 | -------------------------------------------------------------------------------- /src/NextjsStaticHosting.AspNetCore/NextjsStaticHosting.AspNetCore.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1;net6.0;net7.0 5 | true 6 | 7 | 8 | 9 9 | 10 | 11 | 12 | NextjsStaticHosting.AspNetCore 13 | 0.9.2 14 | preview 15 | David Nissimoff 16 | Host Next.js applications in production without Node.js. The most performant way there is, on ASP .NET Core. 17 | MIT 18 | aspnetcore;nextjs;next.js;next;react;nodejs;node.js;node 19 | https://github.com/davidnx/NextjsStaticHosting-AspNetCore 20 | https://github.com/davidnx/NextjsStaticHosting-AspNetCore 21 | logo.png 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | -------------------------------------------------------------------------------- /src/NextjsStaticHosting.AspNetCore/NextjsStaticHostingExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Microsoft.AspNetCore.Builder; 3 | using Microsoft.AspNetCore.Routing; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Logging; 6 | using Microsoft.Extensions.Options; 7 | using NextjsStaticHosting.AspNetCore.Internals; 8 | 9 | namespace NextjsStaticHosting.AspNetCore 10 | { 11 | /// 12 | /// Extension method to add support for hosting a Next.js statically generated client-side app on ASP .NET Core. 13 | /// 14 | public static class NextjsStaticHostingExtensions 15 | { 16 | /// 17 | /// Adds necessary dependencies to the DI container. 18 | /// 19 | /// 20 | /// 21 | /// options.RootPath = "wwwroot/clientapp"); 25 | /// } 26 | /// ]]> 27 | /// 28 | /// 29 | public static void AddNextjsStaticHosting(this IServiceCollection services, Action configureAction = null) 30 | { 31 | _ = services ?? throw new ArgumentNullException(nameof(services)); 32 | 33 | if (configureAction != null) 34 | { 35 | services.Configure(configureAction); 36 | } 37 | 38 | services.AddSingleton(); 39 | services.AddSingleton(); 40 | 41 | // Add YARP (used only when `NextjsStaticHostingOptions.ProxyToDevServer` is true). 42 | // See also: `ProxyToDevServerMiddleware`. 43 | services.AddHttpForwarder(); 44 | } 45 | 46 | /// 47 | /// Registers endpoints for Next.js pages found in the configured . 48 | /// This ensures that the correct static Next.js pages will be served at the right paths. 49 | /// 50 | /// For example, say your client application is composed of the following files: 51 | /// 52 | /// /post/create.html 53 | /// /post/[pid].html 54 | /// /post/[...slug].html 55 | /// 56 | /// 57 | /// 58 | /// The following routes will be created accordingly: 59 | /// 60 | /// post/create, serving file /post/create.html 61 | /// post/{pid}, serving file /post/[pid].html 62 | /// post/{*slug}, serving file /post/[...slug].html 63 | /// 64 | /// 65 | /// 66 | /// ASP .NET Core Endpoint Routing built-in route precedence rules ensure the same semantics as Next.js expects will apply. 67 | /// E.g., post/create has higher precedence than post/{pid}, which in turn has higher precedence than post/{*slug}. 68 | /// 69 | /// 70 | /// 71 | /// 72 | /// 77 | /// { 78 | /// endpoints.MapNextjsStaticHtmls(); 79 | /// }); 80 | /// 81 | /// app.UseNextjsStaticHosting(); 82 | /// } 83 | /// ]]> 84 | /// 85 | /// 86 | public static IEndpointConventionBuilder MapNextjsStaticHtmls(this IEndpointRouteBuilder endpoints) 87 | { 88 | _ = endpoints ?? throw new ArgumentNullException(nameof(endpoints)); 89 | 90 | var options = endpoints.ServiceProvider.GetRequiredService>(); 91 | if (options.Value.ProxyToDevServer) 92 | { 93 | var logger = endpoints.ServiceProvider.GetRequiredService().CreateLogger("NextjsStaticHostingExtensions"); 94 | logger.LogInformation($"{nameof(NextjsStaticHostingExtensions)} was configured with {nameof(NextjsStaticHostingOptions.ProxyToDevServer)}, skipping dynamic endpoint configuration."); 95 | return new NullEndpointConventionBuilder(); 96 | } 97 | 98 | var staticFileOptionsProvider = endpoints.ServiceProvider.GetRequiredService(); 99 | var dataSource = new NextjsEndpointDataSource(endpoints, staticFileOptionsProvider); 100 | endpoints.DataSources.Add(dataSource); 101 | return dataSource.DefaultBuilder; 102 | } 103 | 104 | /// 105 | /// Registers a static files middleware that will serve files from the configured . 106 | /// This is required to serve non-html files including JavaScript, CSS, and any other artifacts produced by Next.js export outputs. 107 | /// 108 | /// 109 | /// 110 | /// 115 | /// { 116 | /// endpoints.MapNextjsStaticHtmls(); 117 | /// }); 118 | /// 119 | /// app.UseNextjsStaticHosting(); 120 | /// } 121 | /// ]]> 122 | /// 123 | /// 124 | public static void UseNextjsStaticHosting(this IApplicationBuilder app) 125 | { 126 | _ = app ?? throw new ArgumentNullException(nameof(app)); 127 | 128 | var options = app.ApplicationServices.GetRequiredService>(); 129 | if (options.Value.ProxyToDevServer) 130 | { 131 | app.UseMiddleware(); 132 | return; 133 | } 134 | 135 | var staticFileOptionsProvider = app.ApplicationServices.GetRequiredService(); 136 | app.UseStaticFiles(staticFileOptionsProvider.StaticFileOptions); 137 | } 138 | 139 | private class NullEndpointConventionBuilder : IEndpointConventionBuilder 140 | { 141 | public void Add(Action convention) 142 | { 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/NextjsStaticHosting.AspNetCore/NextjsStaticHostingOptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace NextjsStaticHosting.AspNetCore 4 | { 5 | /// 6 | /// Options for hosting exported Next.js client-side applications on ASP .NET Core. 7 | /// 8 | public class NextjsStaticHostingOptions 9 | { 10 | /// 11 | /// Relative path from the app's content path where the Next.js client app binaries are stored. 12 | /// 13 | /// 14 | /// Usually you should not provide a leading slash in this value. 15 | /// This is used with 16 | /// with 17 | /// as the base path. 18 | /// Specifying a rooted path here (e.g. /foo) is usually undesirable and may lead to security issues 19 | /// (i.e., the resulting physical path would be DRIVE:\foo because of the leading slash). 20 | /// 21 | public string RootPath { get; set; } 22 | 23 | /// 24 | /// Uri to the Next.js dev server. Only applicable when is true. 25 | /// 26 | public string DevServer { get; set; } 27 | 28 | /// 29 | /// Whether to proxy to a Next.js dev server instead of hosting static exported filed. 30 | /// 31 | public bool ProxyToDevServer { get; set; } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /src/NextjsStaticHosting.AspNetCore/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/davidnx/NextjsStaticHosting-AspNetCore/2274bebbbfa801a627bd78ccbf970f20e57beaa0/src/NextjsStaticHosting.AspNetCore/logo.png -------------------------------------------------------------------------------- /test/NextjsStaticHosting.AspNetCore.Test/Internals/NextjsEndpointDataSourceTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections; 3 | using System.Collections.Generic; 4 | using System.IO; 5 | using System.Reflection; 6 | using System.Threading.Tasks; 7 | using FluentAssertions; 8 | using Microsoft.AspNetCore.Builder; 9 | using Microsoft.AspNetCore.Hosting; 10 | using Microsoft.AspNetCore.Http; 11 | using Microsoft.AspNetCore.Routing; 12 | using Microsoft.AspNetCore.Routing.Patterns; 13 | using Microsoft.Extensions.FileProviders; 14 | using Microsoft.Extensions.Options; 15 | using Moq; 16 | using Xunit; 17 | 18 | namespace NextjsStaticHosting.AspNetCore.Internals.Test 19 | { 20 | public class NextjsEndpointDataSourceTests 21 | { 22 | [Fact] 23 | public void Basics_Work() 24 | { 25 | // Arrange 26 | var env = new Mock(); 27 | env.SetupGet(e => e.ContentRootPath).Returns(@"Y:\test\中文"); 28 | var options = Options.Create(new NextjsStaticHostingOptions { RootPath = "a/ê" }); 29 | 30 | var fileProvider = new Mock(); 31 | fileProvider 32 | .Setup(f => f.GetDirectoryContents(string.Empty)) 33 | .Returns(new TestDirectoryContents(new TestFile("abc.html"), new TestFile("d&f.html"), new TestFile("[id].html"), new TestDirectory("nested 中文"))); 34 | fileProvider 35 | .Setup(f => f.GetDirectoryContents("/nested 中文")) 36 | .Returns(new TestDirectoryContents(new TestFile("123.html"), new TestFile("[...slug].html"), new TestFile("234.jpg"))); 37 | 38 | var fileProviderFactory = new Mock(); 39 | fileProviderFactory.Setup(f => f.CreateFileProvider(@"Y:\test\中文\a/ê")).Returns(fileProvider.Object); 40 | var staticFileOptionsProvider = new StaticFileOptionsProvider(env.Object, fileProviderFactory.Object, options); 41 | 42 | var appBuilder = new Mock(); 43 | appBuilder.Setup(a => a.Build()).Returns(_ => Task.CompletedTask); 44 | var endpointRouteBuilder = new Mock(); 45 | endpointRouteBuilder 46 | .Setup(e => e.CreateApplicationBuilder()) 47 | .Returns(appBuilder.Object); 48 | 49 | var sut = new NextjsEndpointDataSource(endpointRouteBuilder.Object, staticFileOptionsProvider); 50 | 51 | // Act 52 | var endpoints = sut.Endpoints; 53 | 54 | // Assert 55 | endpoints.Count.Should().Be(5); 56 | endpoints[0].DisplayName.Should().Be("Next.js /abc.html"); 57 | endpoints[0].Metadata.GetMetadata().Path.Should().Be("/abc.html"); 58 | GetPatternString(endpoints[0]).Should().Be("abc"); 59 | 60 | endpoints[1].DisplayName.Should().Be("Next.js /d&f.html"); 61 | endpoints[1].Metadata.GetMetadata().Path.Should().Be("/d&f.html"); 62 | GetPatternString(endpoints[1]).Should().Be("d&f"); 63 | 64 | endpoints[2].DisplayName.Should().Be("Next.js /[id].html"); 65 | endpoints[2].Metadata.GetMetadata().Path.Should().Be("/[id].html"); 66 | GetPatternString(endpoints[2]).Should().Be("{id}"); 67 | 68 | endpoints[3].DisplayName.Should().Be("Next.js /nested 中文/123.html"); 69 | endpoints[3].Metadata.GetMetadata().Path.Should().Be("/nested 中文/123.html"); 70 | GetPatternString(endpoints[3]).Should().Be("nested 中文/123"); 71 | 72 | endpoints[4].DisplayName.Should().Be("Next.js /nested 中文/[...slug].html"); 73 | endpoints[4].Metadata.GetMetadata().Path.Should().Be("/nested 中文/[...slug].html"); 74 | GetPatternString(endpoints[4]).Should().Be("nested 中文/{*slug}"); 75 | 76 | static string GetPatternString(Endpoint endpoint) 77 | { 78 | var routeEndpoint = (RouteEndpoint)endpoint; 79 | var method = typeof(RoutePattern).GetMethod("DebuggerToString", BindingFlags.NonPublic | BindingFlags.Instance); 80 | return (string)method.Invoke(routeEndpoint.RoutePattern, null); 81 | } 82 | } 83 | 84 | private class TestDirectoryContents : IDirectoryContents, IFileInfo 85 | { 86 | private readonly IEnumerable files; 87 | 88 | public TestDirectoryContents(params IFileInfo[] files) 89 | { 90 | this.files = files; 91 | } 92 | 93 | public bool Exists => true; 94 | 95 | public long Length => throw new NotSupportedException(); 96 | 97 | public string PhysicalPath => throw new NotSupportedException(); 98 | 99 | public string Name => throw new NotSupportedException(); 100 | 101 | public DateTimeOffset LastModified => throw new NotSupportedException(); 102 | 103 | public bool IsDirectory => true; 104 | 105 | public Stream CreateReadStream() 106 | { 107 | throw new NotSupportedException(); 108 | } 109 | 110 | public IEnumerator GetEnumerator() => this.files.GetEnumerator(); 111 | 112 | IEnumerator IEnumerable.GetEnumerator() => this.GetEnumerator(); 113 | } 114 | 115 | private class TestFileInfo : IFileInfo 116 | { 117 | protected TestFileInfo(string name, bool isDirectory) 118 | { 119 | this.Name = name; 120 | this.IsDirectory = isDirectory; 121 | } 122 | 123 | public string Name { get; } 124 | 125 | public bool IsDirectory { get; } 126 | 127 | public bool Exists => throw new NotImplementedException(); 128 | 129 | public DateTimeOffset LastModified => throw new NotImplementedException(); 130 | 131 | public long Length => throw new NotImplementedException(); 132 | 133 | public string PhysicalPath => throw new NotImplementedException(); 134 | 135 | public Stream CreateReadStream() => throw new NotImplementedException(); 136 | } 137 | 138 | private class TestDirectory : TestFileInfo 139 | { 140 | public TestDirectory(string name) 141 | : base(name, isDirectory: true) 142 | { } 143 | } 144 | 145 | private class TestFile : TestFileInfo 146 | { 147 | public TestFile(string name) 148 | : base(name, isDirectory: false) 149 | { } 150 | } 151 | } 152 | } 153 | -------------------------------------------------------------------------------- /test/NextjsStaticHosting.AspNetCore.Test/NextjsStaticHosting.AspNetCore.Test.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net7.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | all 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | 18 | 19 | all 20 | runtime; build; native; contentfiles; analyzers; buildtransitive 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | --------------------------------------------------------------------------------