diff --git a/build/version.props b/build/version.props index 991f961a8..88fe86b92 100644 --- a/build/version.props +++ b/build/version.props @@ -2,7 +2,7 @@ 8 0 - 8 + 9 $(VersionMajor).$(VersionMinor).$(VersionPatch) diff --git a/readme.md b/readme.md index 70643ffa7..000441d5f 100644 --- a/readme.md +++ b/readme.md @@ -422,10 +422,6 @@ Util 配套代码生成器, 简单易用, 可解决大部分机械工作. ## Util Angular UI 特点 -- ### 使用 Visual Studio 开发工具 - - 前端开发一般使用 Visual Studio Code , 不过 Util Angular UI主要使用 Razor 页面,使用 Visual Studio 更方便. - - ### 组件扩展支持 除了支持 Ng Zorro 原生功能外,Util UI还对常用组件进行了扩展. @@ -580,31 +576,11 @@ Util Angular UI 主要由 util-angular 和 Util.Ui.NgZorro 两个库提供支持 ## Util Angular UI 已知缺陷 -Util Angular UI 在提供大量支持的同时,也存在一些缺陷. - - - ### 开发阶段运行比较缓慢 - - 与 Visual Studio Code 相比,使用 Visual Studio 开发 Angular 项目要慢一些,如果使用了 Resharper 插件,则会更慢. - - 除开发工具影响外, Util Angular UI 在开发阶段需要启用 Angular JIT( 即时编译 ), 运行会变慢. - - 另外, Util Angular UI 在开发阶段需要访问 Razor 页面,每当项目启动,Angular 主模块加载的所有组件都会发出 Razor 页面请求. - - 不过运行缓慢仅存在于开发阶段,一旦发布,则与纯前端开发方式的运行速度相同. - - - ### 无法使用 Angular 常规延迟加载方式 - - 你不能使用 loadChildren 延迟加载模块,这是因为开发阶段组件的 templateUrl 指向 Razor 页面地址, 必须使用 Angular JIT 模式,等待运行时再获取组件模板. - - 这个问题从 Angular 13 开始出现, Angular 13弃用了传统的视图引擎, 使用 loadChildren 加载指向 Razor 页面地址的组件会报异常. - - 解决它的方法是使用微前端方案延迟加载模块, 当然你也可以回退到 Angular 13之前的版本. - - 在同一个 Util Angular UI 项目中,你必须把所有的子模块加载到主模块中,并配置微前端将子模块发布为可独立加载包. +Util Angular UI 所有已知缺陷均已解决. ## Util Angular UI 适合你吗? -Util Angular UI 是为 .Net 全栈工程师准备的,如果你更喜欢使用 Visual Studio 开发,喜欢代码提示,喜欢更简洁的语法,希望开发的成本更低,它就适合你. +Util Angular UI 是为 .Net 全栈工程师准备的,如果你喜欢更简洁的语法,希望开发的成本更低,它就适合你. ## 参考应用框架 diff --git a/src/Util.AspNetCore/Helpers/Web.cs b/src/Util.AspNetCore/Helpers/Web.cs index 6e375ccf2..f28ef9e83 100644 --- a/src/Util.AspNetCore/Helpers/Web.cs +++ b/src/Util.AspNetCore/Helpers/Web.cs @@ -368,8 +368,8 @@ public static async Task DownloadAsync( byte[] bytes, string fileName, Encoding fileName = fileName.Replace( " ", "" ); fileName = UrlEncode( fileName, encoding ); Response.ContentType = "application/octet-stream"; - Response.Headers.Add( "Content-Disposition", $"attachment; filename={fileName}" ); - Response.Headers.Add( "Content-Length", bytes.Length.ToString() ); + Response.Headers.Append( "Content-Disposition", $"attachment; filename={fileName}" ); + Response.Headers.Append( "Content-Length", bytes.Length.ToString() ); await Response.Body.WriteAsync( bytes, 0, bytes.Length ); } diff --git a/src/Util.Core/Helpers/Environment.cs b/src/Util.Core/Helpers/Environment.cs index 579345c2a..d81cbd608 100644 --- a/src/Util.Core/Helpers/Environment.cs +++ b/src/Util.Core/Helpers/Environment.cs @@ -20,6 +20,10 @@ public static class Environment { /// 换行符 /// public static string NewLine => System.Environment.NewLine; + /// + /// 是否测试环境 + /// + public static bool IsTest { get; set; } /// /// 设置环境变量 diff --git a/src/Util.Core/Helpers/Url.cs b/src/Util.Core/Helpers/Url.cs index aed5fa3be..ee714a4a3 100644 --- a/src/Util.Core/Helpers/Url.cs +++ b/src/Util.Core/Helpers/Url.cs @@ -16,6 +16,11 @@ public static string JoinPath( params string[] paths ) { return string.Empty; var firstPath = paths.First(); var lastPath = paths.Last(); + string schema = string.Empty; + if ( firstPath.StartsWith( "http:", StringComparison.OrdinalIgnoreCase ) ) + schema = "http://"; + if ( firstPath.StartsWith( "https:", StringComparison.OrdinalIgnoreCase ) ) + schema = "https://"; paths = paths.Select( t => t.Trim( '/' ) ).ToArray(); var result = Path.Combine( paths ).Replace( @"\", "/" ); if ( paths.Any( path => path.StartsWith( "." ) ) ) { @@ -26,6 +31,9 @@ public static string JoinPath( params string[] paths ) { result = $"/{result}"; if ( lastPath.EndsWith( '/' ) ) result = $"{result}/"; - return result; + result = result.RemoveStart( "http:/" ).RemoveStart( "https:/" ); + if (schema.IsEmpty()) + return result; + return schema + result.RemoveStart( "/" ); } } \ No newline at end of file diff --git a/src/Util.Generators.Razor/RazorTemplate.cs b/src/Util.Generators.Razor/RazorTemplate.cs index c96811b5a..391666c0f 100644 --- a/src/Util.Generators.Razor/RazorTemplate.cs +++ b/src/Util.Generators.Razor/RazorTemplate.cs @@ -2,7 +2,7 @@ using Util.Generators.Templates; using Util.Templates; -namespace Util.Generators.Razor; +namespace Util.Generators.Razor; /// /// Razor模板 @@ -82,8 +82,10 @@ protected virtual async Task RenderResult( EntityContext context ) { /// /// 实体上下文 /// 渲染结果 - protected virtual async Task WriteFile( EntityContext context,string result ) { - if( context.Output.Path.IsEmpty() ) + protected virtual async Task WriteFile( EntityContext context, string result ) { + if ( context.Output.Path.IsEmpty() ) + return; + if ( result.IsEmpty() ) return; await Util.Helpers.File.WriteAsync( context.Output.Path, result ); } diff --git a/src/Util.Logging.Serilog/02-Util.Logging.Serilog.csproj b/src/Util.Logging.Serilog/02-Util.Logging.Serilog.csproj index d9ad5c057..94c3d9478 100644 --- a/src/Util.Logging.Serilog/02-Util.Logging.Serilog.csproj +++ b/src/Util.Logging.Serilog/02-Util.Logging.Serilog.csproj @@ -27,10 +27,10 @@ - - + + - + diff --git a/src/Util.Ui.Angular/Extensions/ConfigExtensions.cs b/src/Util.Ui.Angular/Extensions/ConfigExtensions.cs index 99cfaa45b..d6f40875a 100644 --- a/src/Util.Ui.Angular/Extensions/ConfigExtensions.cs +++ b/src/Util.Ui.Angular/Extensions/ConfigExtensions.cs @@ -1,7 +1,7 @@ using Util.Ui.Angular.Configs; using Util.Ui.Configs; -namespace Util.Ui.Angular.Extensions; +namespace Util.Ui.Angular.Extensions; /// /// 配置扩展 @@ -13,13 +13,30 @@ public static class ConfigExtensions { /// 配置 public static Config CopyRemoveAttributes( this Config config ) { var result = config.Copy(); - result.RemoveAttribute( UiConst.Id ); - result.RemoveAttribute( AngularConst.RawId ); - result.RemoveAttribute( UiConst.Name ); - result.RemoveAttribute( AngularConst.BindName ); - result.RemoveAttribute( UiConst.Style ); - result.RemoveAttribute( UiConst.Class ); result.OutputAttributes.Clear(); + result.AllAttributes.Clear(); + LoadConfig( config, result, UiConst.Required ); + LoadConfig( config, result, UiConst.RequiredMessage ); + LoadConfig( config, result, UiConst.Suffix ); + LoadConfig( config, result, AngularConst.BindSuffix ); + LoadConfig( config, result, UiConst.Extra ); + LoadConfig( config, result, AngularConst.BindExtra ); + LoadConfig( config, result, UiConst.ErrorTip ); + LoadConfig( config, result, AngularConst.BindErrorTip ); + LoadConfig( config, result, UiConst.SuccessTip ); + LoadConfig( config, result, AngularConst.BindSuccessTip ); + LoadConfig( config, result, UiConst.ValidatingTip ); + LoadConfig( config, result, AngularConst.BindValidatingTip ); + LoadConfig( config, result, UiConst.WarningTip ); + LoadConfig( config, result, AngularConst.BindWarningTip ); return result; } + + /// + /// 加载配置 + /// + private static void LoadConfig( Config from, Config to, string name ) { + var value = from.GetValue( name ); + to.SetAttribute( name, value ); + } } \ No newline at end of file diff --git a/src/Util.Ui.NgZorro/AppBuilderExtensions.cs b/src/Util.Ui.NgZorro/AppBuilderExtensions.cs index 2b47c9378..db1bf264d 100644 --- a/src/Util.Ui.NgZorro/AppBuilderExtensions.cs +++ b/src/Util.Ui.NgZorro/AppBuilderExtensions.cs @@ -31,6 +31,8 @@ public static IAppBuilder AddNgZorro( this IAppBuilder builder, Action(); services.TryAddSingleton(); services.TryAddSingleton(); + if ( options.EnableWatchRazor ) + services.AddHostedService(); ConfigSpaStaticFiles( services, options ); ConfigRazorOptions( services, options ); ConfigNgZorroOptions( services, setupAction ); @@ -56,7 +58,11 @@ void Action( RazorOptions t ) { t.GenerateHtmlBasePath = options.GenerateHtmlBasePath; t.GenerateHtmlFolder = options.GenerateHtmlFolder; t.GenerateHtmlSuffix = options.GenerateHtmlSuffix; + t.EnableWatchRazor = options.EnableWatchRazor; + t.StartInitDelay = options.StartInitDelay; t.HtmlRenderDelayOnRazorChange = options.HtmlRenderDelayOnRazorChange; + t.EnablePreheat = options.EnablePreheat; + t.EnableOverrideHtml = options.EnableOverrideHtml; } services.Configure( (Action)Action ); } diff --git a/src/Util.Ui.NgZorro/Components/Buttons/ButtonGroupTagHelper.cs b/src/Util.Ui.NgZorro/Components/Buttons/ButtonGroupTagHelper.cs index e2e3eee38..2c1f03ef4 100644 --- a/src/Util.Ui.NgZorro/Components/Buttons/ButtonGroupTagHelper.cs +++ b/src/Util.Ui.NgZorro/Components/Buttons/ButtonGroupTagHelper.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Razor.TagHelpers; using Util.Ui.Angular.TagHelpers; -using Util.Ui.Configs; using Util.Ui.NgZorro.Components.Buttons.Renders; using Util.Ui.NgZorro.Enums; using Util.Ui.Renders; diff --git a/src/Util.Ui.NgZorro/Components/Descriptions/Builders/DescriptionItemBuilder.cs b/src/Util.Ui.NgZorro/Components/Descriptions/Builders/DescriptionItemBuilder.cs index 268e8a979..0aa4a0267 100644 --- a/src/Util.Ui.NgZorro/Components/Descriptions/Builders/DescriptionItemBuilder.cs +++ b/src/Util.Ui.NgZorro/Components/Descriptions/Builders/DescriptionItemBuilder.cs @@ -93,6 +93,10 @@ private void ConfigValue() { LoadDate( value ); return; } + if ( dataType == DataType.Number ) { + LoadNumber( value ); + return; + } SetContent( "{{" + GetValue(value) + "}}"); } @@ -114,6 +118,13 @@ protected void LoadDate( string value ) { SetContent( $"{{{{{value}|date:\"{format}\"}}}}" ); } + /// + /// 加载数值 + /// + protected void LoadNumber( string value ) { + SetContent( "{{" + value + "}}" ); + } + /// /// 获取值 /// diff --git a/src/Util.Ui.NgZorro/Components/Display/Helpers/DisplayExpressionLoader.cs b/src/Util.Ui.NgZorro/Components/Display/Helpers/DisplayExpressionLoader.cs index b5422a3c6..3b7e27183 100644 --- a/src/Util.Ui.NgZorro/Components/Display/Helpers/DisplayExpressionLoader.cs +++ b/src/Util.Ui.NgZorro/Components/Display/Helpers/DisplayExpressionLoader.cs @@ -1,5 +1,4 @@ -using Util.Ui.Configs; -using Util.Ui.Expressions; +using Util.Ui.Expressions; using Util.Ui.NgZorro.Components.Forms.Configs; using Util.Ui.NgZorro.Enums; using Util.Ui.NgZorro.Expressions; @@ -53,5 +52,9 @@ protected virtual void LoadValue( Config config, ModelExpressionInfo info ) { config.SetAttribute( UiConst.Type, DataType.Date ); return; } + if ( info.IsNumber ) { + config.SetAttribute( UiConst.Type, DataType.Number ); + return; + } } } \ No newline at end of file diff --git a/src/Util.Ui.NgZorro/Components/Inputs/Builders/InputGroupBuilder.cs b/src/Util.Ui.NgZorro/Components/Inputs/Builders/InputGroupBuilder.cs index 16b3cd728..79c273f99 100644 --- a/src/Util.Ui.NgZorro/Components/Inputs/Builders/InputGroupBuilder.cs +++ b/src/Util.Ui.NgZorro/Components/Inputs/Builders/InputGroupBuilder.cs @@ -1,5 +1,4 @@ using Util.Ui.Angular.Configs; -using Util.Ui.Configs; using Util.Ui.NgZorro.Enums; using Util.Ui.NgZorro.Components.Inputs.Configs; using Util.Ui.Angular.Builders; diff --git a/src/Util.Ui.NgZorro/Components/Inputs/Builders/TextareaBuilder.cs b/src/Util.Ui.NgZorro/Components/Inputs/Builders/TextareaBuilder.cs index 2fe93bec9..5f2ac8de2 100644 --- a/src/Util.Ui.NgZorro/Components/Inputs/Builders/TextareaBuilder.cs +++ b/src/Util.Ui.NgZorro/Components/Inputs/Builders/TextareaBuilder.cs @@ -1,5 +1,4 @@ using Util.Ui.Angular.Configs; -using Util.Ui.Configs; using Util.Ui.NgZorro.Components.Base; using Util.Ui.NgZorro.Components.Inputs.Helpers; using Util.Ui.NgZorro.Enums; diff --git a/src/Util.Ui.NgZorro/Components/Popover/PopoverRender.cs b/src/Util.Ui.NgZorro/Components/Popover/PopoverRender.cs index 43c20cb91..81d8e4645 100644 --- a/src/Util.Ui.NgZorro/Components/Popover/PopoverRender.cs +++ b/src/Util.Ui.NgZorro/Components/Popover/PopoverRender.cs @@ -1,6 +1,5 @@ using Util.Ui.Angular.Configs; using Util.Ui.Builders; -using Util.Ui.Configs; using Util.Ui.NgZorro.Enums; namespace Util.Ui.NgZorro.Components.Popover; diff --git a/src/Util.Ui.NgZorro/Components/Tables/Builders/TableColumnBuilder.cs b/src/Util.Ui.NgZorro/Components/Tables/Builders/TableColumnBuilder.cs index a0b573a32..79f9d7df3 100644 --- a/src/Util.Ui.NgZorro/Components/Tables/Builders/TableColumnBuilder.cs +++ b/src/Util.Ui.NgZorro/Components/Tables/Builders/TableColumnBuilder.cs @@ -1,8 +1,10 @@ using Util.Ui.Angular.Builders; using Util.Ui.Angular.Configs; +using Util.Ui.Angular.Extensions; using Util.Ui.Builders; using Util.Ui.NgZorro.Components.Tables.Builders.Contents; using Util.Ui.NgZorro.Components.Tables.Configs; +using Util.Ui.NgZorro.Directives.Tooltips; namespace Util.Ui.NgZorro.Components.Tables.Builders; @@ -289,6 +291,7 @@ public TableColumnBuilder EnableCustomColumn() { public TableColumnBuilder Events() { AttributeIfNotEmpty( "(nzCheckedChange)", _config.GetValue( UiConst.OnCheckedChange ) ); AttributeIfNotEmpty( "(nzExpandChange)", _config.GetValue( UiConst.OnExpandChange ) ); + this.OnClick( _config ); return this; } @@ -301,7 +304,7 @@ public override void Config() { .ShowExpand().Expand() .Left().Right().Align().BreakWord().Ellipsis() .IndentSize().CellControl().EnableCustomColumn() - .Events(); + .Tooltip( _config ).Events(); ConfigContent(); } diff --git a/src/Util.Ui.NgZorro/Components/Tables/Builders/TableColumnDisplayBuilder.cs b/src/Util.Ui.NgZorro/Components/Tables/Builders/TableColumnDisplayBuilder.cs index 153d1a4aa..38c7ac427 100644 --- a/src/Util.Ui.NgZorro/Components/Tables/Builders/TableColumnDisplayBuilder.cs +++ b/src/Util.Ui.NgZorro/Components/Tables/Builders/TableColumnDisplayBuilder.cs @@ -1,6 +1,5 @@ using Util.Ui.Angular.Builders; using Util.Ui.Angular.Extensions; -using Util.Ui.Configs; using Util.Ui.NgZorro.Components.Tables.Configs; namespace Util.Ui.NgZorro.Components.Tables.Builders; diff --git a/src/Util.Ui.NgZorro/Components/Tables/Builders/TableSettingsBuilder.cs b/src/Util.Ui.NgZorro/Components/Tables/Builders/TableSettingsBuilder.cs index 6a8c1a01b..5dde35288 100644 --- a/src/Util.Ui.NgZorro/Components/Tables/Builders/TableSettingsBuilder.cs +++ b/src/Util.Ui.NgZorro/Components/Tables/Builders/TableSettingsBuilder.cs @@ -39,9 +39,9 @@ public TableShareConfig GetTableShareConfig() { /// 设置表格尺寸 /// public TableSettingsBuilder IsTreeTable() { - if (_tableShareConfig.IsTreeTable == false) + if ( _tableShareConfig.IsTreeTable == false ) return this; - Attribute( "[isTreeTable]","true" ); + Attribute( "[isTreeTable]", "true" ); return this; } @@ -75,8 +75,8 @@ public TableSettingsBuilder Scroll() { /// 配置是否启用固定列 /// public TableSettingsBuilder EnableFixedColumn() { - if( _tableShareConfig.IsEnableFixedColumn ) - Attribute( "[enableFixedColumn]","true" ); + if ( _tableShareConfig.IsEnableFixedColumn ) + Attribute( "[enableFixedColumn]", "true" ); return this; } @@ -98,9 +98,9 @@ private string GetColumnsJson() { AddRadioColumn( result ); AddCheckboxColumn( result ); AddLineNumberColumn( result ); - result.AddRange( _tableShareConfig.Columns.Select( column => column.ToCustomColumn() ) ); + result.AddRange( _tableShareConfig.Columns.Where( column => column.IsInner == false ).Select( column => column.ToCustomColumn() ) ); var json = Util.Helpers.Json.ToJson( result, new JsonOptions { ToSingleQuotes = true, IgnoreNullValues = true } ); - return json.Replace($"'{GetCheckboxWidth()}'", GetCheckboxWidth() ) + return json.Replace( $"'{GetCheckboxWidth()}'", GetCheckboxWidth() ) .Replace( $"'{GetRadioWidth()}'", GetRadioWidth() ) .Replace( $"'{GetLineNumberWidth()}'", GetLineNumberWidth() ); } diff --git a/src/Util.Ui.NgZorro/Components/Tables/Configs/TableColumnDisplayShareConfig.cs b/src/Util.Ui.NgZorro/Components/Tables/Configs/TableColumnDisplayShareConfig.cs new file mode 100644 index 000000000..99e708fce --- /dev/null +++ b/src/Util.Ui.NgZorro/Components/Tables/Configs/TableColumnDisplayShareConfig.cs @@ -0,0 +1,7 @@ +namespace Util.Ui.NgZorro.Components.Tables.Configs; + +/// +/// 表格编辑列显示区域共享配置 +/// +public class TableColumnDisplayShareConfig { +} \ No newline at end of file diff --git a/src/Util.Ui.NgZorro/Components/Tables/Helpers/ColumnInfo.cs b/src/Util.Ui.NgZorro/Components/Tables/Helpers/ColumnInfo.cs index 93238b3bf..9237dd84f 100644 --- a/src/Util.Ui.NgZorro/Components/Tables/Helpers/ColumnInfo.cs +++ b/src/Util.Ui.NgZorro/Components/Tables/Helpers/ColumnInfo.cs @@ -64,6 +64,10 @@ public class ColumnInfo { /// 是否启用拖动调整列宽 /// public bool IsEnableResizable { get; set; } + /// + /// 是否内部列 + /// + public bool IsInner { get; set; } /// /// 转换为自定义列 diff --git a/src/Util.Ui.NgZorro/Components/Tables/Helpers/TableColumnDisplayService.cs b/src/Util.Ui.NgZorro/Components/Tables/Helpers/TableColumnDisplayService.cs new file mode 100644 index 000000000..80b0f3514 --- /dev/null +++ b/src/Util.Ui.NgZorro/Components/Tables/Helpers/TableColumnDisplayService.cs @@ -0,0 +1,40 @@ +using Util.Ui.NgZorro.Components.Tables.Configs; + +namespace Util.Ui.NgZorro.Components.Tables.Helpers; + +/// +/// 表格编辑列显示服务 +/// +public class TableColumnDisplayService { + /// + /// 配置 + /// + private readonly Config _config; + /// + /// 表格编辑列显示区域共享配置 + /// + private TableColumnDisplayShareConfig _shareConfig; + + /// + /// 初始化表格编辑列显示服务 + /// + /// 配置 + public TableColumnDisplayService( Config config ) { + _config = config; + } + + /// + /// 初始化 + /// + public void Init() { + CreateShareConfig(); + } + + /// + /// 创建共享配置 + /// + private void CreateShareConfig() { + _shareConfig = new TableColumnDisplayShareConfig(); + _config.SetValueToItems( _shareConfig ); + } +} \ No newline at end of file diff --git a/src/Util.Ui.NgZorro/Components/Tables/Helpers/TableColumnService.cs b/src/Util.Ui.NgZorro/Components/Tables/Helpers/TableColumnService.cs index 3de69a03f..a63003d98 100644 --- a/src/Util.Ui.NgZorro/Components/Tables/Helpers/TableColumnService.cs +++ b/src/Util.Ui.NgZorro/Components/Tables/Helpers/TableColumnService.cs @@ -103,6 +103,8 @@ private ColumnInfo GetColumnInfo() { AclElseTemplateId = _config.GetValue( UiConst.AclElseTemplateId ), IsEnableResizable = _config.GetValue( UiConst.EnableResizable ) }; + if ( IsInColumnDisplay() ) + result.IsInner = true; return result; } @@ -128,4 +130,12 @@ private string GetOperationTitle() { return "util.operation"; return "Operation"; } + + /// + /// 是否包含在显示列中 + /// + private bool IsInColumnDisplay() { + var displayShareConfig = _config.GetValueFromItems(); + return displayShareConfig != null; + } } \ No newline at end of file diff --git a/src/Util.Ui.NgZorro/Components/Tables/TableColumnControlTagHelper.cs b/src/Util.Ui.NgZorro/Components/Tables/TableColumnControlTagHelper.cs index 470876982..47cb7e763 100644 --- a/src/Util.Ui.NgZorro/Components/Tables/TableColumnControlTagHelper.cs +++ b/src/Util.Ui.NgZorro/Components/Tables/TableColumnControlTagHelper.cs @@ -1,6 +1,5 @@ using Microsoft.AspNetCore.Razor.TagHelpers; using Util.Ui.Angular.TagHelpers; -using Util.Ui.Configs; using Util.Ui.NgZorro.Components.Tables.Helpers; using Util.Ui.NgZorro.Components.Tables.Renders; using Util.Ui.Renders; diff --git a/src/Util.Ui.NgZorro/Components/Tables/TableColumnDisplayTagHelper.cs b/src/Util.Ui.NgZorro/Components/Tables/TableColumnDisplayTagHelper.cs index e6bcec1bd..4826d3ff3 100644 --- a/src/Util.Ui.NgZorro/Components/Tables/TableColumnDisplayTagHelper.cs +++ b/src/Util.Ui.NgZorro/Components/Tables/TableColumnDisplayTagHelper.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Razor.TagHelpers; using Util.Ui.Angular.TagHelpers; -using Util.Ui.Configs; +using Util.Ui.NgZorro.Components.Tables.Helpers; using Util.Ui.NgZorro.Components.Tables.Renders; using Util.Ui.Renders; @@ -19,6 +19,8 @@ public class TableColumnDisplayTagHelper : AngularTagHelperBase { /// protected override void ProcessBefore( TagHelperContext context, TagHelperOutput output ) { _config = new Config( context, output ); + var service = new TableColumnDisplayService( _config ); + service.Init(); } /// diff --git a/src/Util.Ui.NgZorro/Components/Tables/TableColumnTagHelper.cs b/src/Util.Ui.NgZorro/Components/Tables/TableColumnTagHelper.cs index 28ce813ad..716df3e6a 100644 --- a/src/Util.Ui.NgZorro/Components/Tables/TableColumnTagHelper.cs +++ b/src/Util.Ui.NgZorro/Components/Tables/TableColumnTagHelper.cs @@ -1,6 +1,6 @@ using Microsoft.AspNetCore.Mvc.ViewFeatures; using Microsoft.AspNetCore.Razor.TagHelpers; -using Util.Ui.Angular.TagHelpers; +using Util.Ui.NgZorro.Components.Base; using Util.Ui.NgZorro.Components.Tables.Helpers; using Util.Ui.NgZorro.Components.Tables.Renders; using Util.Ui.NgZorro.Enums; @@ -12,7 +12,7 @@ namespace Util.Ui.NgZorro.Components.Tables; /// 表格单元格,生成的标签为<td></td> /// [HtmlTargetElement( "util-td" )] -public class TableColumnTagHelper : AngularTagHelperBase { +public class TableColumnTagHelper : TooltipTagHelperBase { /// /// 配置 /// @@ -184,6 +184,10 @@ public class TableColumnTagHelper : AngularTagHelperBase { /// (nzExpandChange),展开状态变化事件,类型: EventEmitter<boolean> /// public string OnExpandChange { get; set; } + /// + /// (click),单击事件 + /// + public string OnClick { get; set; } /// protected override void ProcessBefore( TagHelperContext context, TagHelperOutput output ) { diff --git a/src/Util.Ui.NgZorro/Directives/Tooltips/TagBuilderExtensions.cs b/src/Util.Ui.NgZorro/Directives/Tooltips/TagBuilderExtensions.cs index d2fd49ce6..beee3de02 100644 --- a/src/Util.Ui.NgZorro/Directives/Tooltips/TagBuilderExtensions.cs +++ b/src/Util.Ui.NgZorro/Directives/Tooltips/TagBuilderExtensions.cs @@ -1,6 +1,5 @@ using Util.Ui.Angular.Configs; using Util.Ui.Builders; -using Util.Ui.Configs; using Util.Ui.NgZorro.Configs; using Util.Ui.NgZorro.Enums; using Util.Ui.NgZorro.Extensions; diff --git a/src/Util.Ui.NgZorro/Enums/DataType.cs b/src/Util.Ui.NgZorro/Enums/DataType.cs index 87d6dec20..26e1ce317 100644 --- a/src/Util.Ui.NgZorro/Enums/DataType.cs +++ b/src/Util.Ui.NgZorro/Enums/DataType.cs @@ -13,6 +13,10 @@ public enum DataType { /// Date, /// + /// 数值 + /// + Number, + /// /// 枚举 /// Enum diff --git a/src/Util.Ui.NgZorro/NgZorroOptions.cs b/src/Util.Ui.NgZorro/NgZorroOptions.cs index 58b6568d4..c5d8fdd20 100644 --- a/src/Util.Ui.NgZorro/NgZorroOptions.cs +++ b/src/Util.Ui.NgZorro/NgZorroOptions.cs @@ -1,17 +1,29 @@ using Util.Ui.NgZorro.Enums; -namespace Util.Ui.NgZorro; +namespace Util.Ui.NgZorro; /// /// NgZorro配置 /// public class NgZorroOptions { + /// + /// 初始化NgZorro配置 + /// + public NgZorroOptions() { + if ( Util.Helpers.Environment.IsTest ) { + EnableDefaultOptionText = false; + EnableI18n = false; + EnableAllowClear = false; + EnableWatchRazor = false; + } + } + /// /// Spa静态文件根路径,默认值: ClientApp /// public string RootPath { get; set; } = "ClientApp"; /// - /// 是否生成html + /// 是否自动生成html,默认值: false /// internal bool IsGenerateHtml { get; set; } /// @@ -27,25 +39,41 @@ public class NgZorroOptions { /// public string GenerateHtmlSuffix { get; set; } = "component.html"; /// + /// Razor监听服务启动初始化的延迟时间,单位: 毫秒, 默认值:1000, 注意: 需要等待Web服务启动完成才能开始初始化 + /// + public int StartInitDelay { get; set; } = 1000; + /// /// 修改Razor页面生成Html文件的延迟时间,单位: 毫秒, 默认值:100 ,注意: 延迟太短可能导致生成异常 /// public int HtmlRenderDelayOnRazorChange { get; set; } = 100; /// - /// 是否启用默认项文本 + /// 是否启用默认项文本,默认值: true /// - public bool EnableDefaultOptionText { get; set; } + public bool EnableDefaultOptionText { get; set; } = true; /// - /// 是否启用多语言 + /// 是否启用多语言,默认值: true /// - public bool EnableI18n { get; set; } + public bool EnableI18n { get; set; } = true; /// - /// 是否启用全局输入框设置 autocomplete="off" + /// 是否启用全局输入框设置 autocomplete="off",默认值: false /// public bool EnableAutocompleteOff { get; set; } /// - /// 是否启用允许清除输入框 + /// 是否启用允许清除输入框,默认值: true + /// + public bool EnableAllowClear { get; set; } = true; + /// + /// 是否启用Razor监视服务,默认值: true + /// + public bool EnableWatchRazor { get; set; } = true; + /// + /// 启动Razor监视服务时是否预热,默认值: true + /// + public bool EnablePreheat { get; set; } = true; + /// + /// Razor生成是否覆盖已存在的html文件,默认值: true /// - public bool EnableAllowClear { get; set; } + public bool EnableOverrideHtml { get; set; } = true; /// /// 获取表格布尔列内容操作 /// diff --git a/src/Util.Ui.NgZorro/WatchHostedService.cs b/src/Util.Ui.NgZorro/WatchHostedService.cs new file mode 100644 index 000000000..f5133b8bb --- /dev/null +++ b/src/Util.Ui.NgZorro/WatchHostedService.cs @@ -0,0 +1,35 @@ +using Util.Ui.Razor; + +namespace Util.Ui.NgZorro; + +/// +/// Razor页面监视服务 +/// +public class WatchHostedService : IHostedService { + /// + /// Razor页面监听服务 + /// + private readonly IRazorWatchService _service; + + /// + /// 初始化Razor页面监视服务 + /// + /// Razor页面监听服务 + public WatchHostedService( IRazorWatchService service ) { + _service = service; + } + + /// + /// 启动服务 + /// + public async Task StartAsync( CancellationToken cancellationToken ) { + await _service.StartAsync( cancellationToken ); + } + + /// + /// 停止服务 + /// + public async Task StopAsync( CancellationToken cancellationToken ) { + await _service.StopAsync( cancellationToken ); + } +} \ No newline at end of file diff --git a/src/Util.Ui.NgZorro/WebApplicationExtensions.cs b/src/Util.Ui.NgZorro/WebApplicationExtensions.cs index 14be2d30a..00f19571d 100644 --- a/src/Util.Ui.NgZorro/WebApplicationExtensions.cs +++ b/src/Util.Ui.NgZorro/WebApplicationExtensions.cs @@ -1,20 +1,39 @@ using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.SpaServices; +using Util.Ui.Sources.Spa.AngularCli; +using Util.Ui.Sources.Spa; -namespace Util.Ui.NgZorro; +namespace Util.Ui.NgZorro; /// /// NgZorro配置扩展 /// public static class WebApplicationExtensions { + /// + /// ClientApp + /// + private const string SourcePath = "ClientApp"; + /// /// 配置NgZorro应用 /// /// Web应用 - /// 配置Spa操作 - public static WebApplication UseNgZorro( this WebApplication app, Action action ) { + public static WebApplication UseNgZorro( this WebApplication app ) { + app.CheckNull( nameof( app ) ); + AddEndpoints( app ); + if ( app.Environment.IsDevelopment() == false ) + return app; + app.UseAngular( spa => { + spa.Options.SourcePath = SourcePath; + spa.UseAngularCliServer( "start" ); + } ); + return app; + } + + /// + /// 添加路由端点 + /// + private static void AddEndpoints( WebApplication app ) { app.CheckNull( nameof( app ) ); - action.CheckNull( nameof( action ) ); app.UseStaticFiles(); app.UseSpaStaticFiles(); app.UseRouting(); @@ -24,7 +43,20 @@ public static WebApplication UseNgZorro( this WebApplication app, Action + /// 配置NgZorro应用 + /// + /// Web应用 + /// 配置Spa操作 + public static WebApplication UseNgZorro( this WebApplication app, Action action ) { + app.CheckNull( nameof( app ) ); + AddEndpoints( app ); + if ( app.Environment.IsDevelopment() == false ) + return app; + if ( action != null ) + app.UseSpa( action ); return app; } @@ -33,12 +65,52 @@ public static WebApplication UseNgZorro( this WebApplication app, Action /// Web应用 /// 开发服务器基地址,范例: http://localhost:5000 - public static WebApplication UseNgZorro( this WebApplication app, string developmentServerBaseUri ) { + /// 是否自动启动Angular服务器,默认值: true + public static WebApplication UseNgZorro( this WebApplication app, string developmentServerBaseUri, bool isAutoStartAngularServer = true ) { app.CheckNull( nameof( app ) ); - return app.UseNgZorro( spa => { - spa.Options.SourcePath = "ClientApp"; - if ( app.Environment.IsDevelopment() ) - spa.UseProxyToSpaDevelopmentServer( developmentServerBaseUri ); + var port = new Uri( developmentServerBaseUri ).Port; + return app.UseNgZorro( port, isAutoStartAngularServer ); + } + + /// + /// 配置NgZorro应用 + /// + /// Web应用 + /// 开发服务器端口号 + /// 是否自动启动Angular服务器,默认值: true + public static WebApplication UseNgZorro( this WebApplication app, int port, bool isAutoStartAngularServer = true ) { + app.CheckNull( nameof( app ) ); + return isAutoStartAngularServer ? UseCustomSpa( app, port ) : UseSpa( app, $"http://localhost:{port}" ); + } + + /// + /// 使用官方SPA实现 + /// + private static WebApplication UseSpa( WebApplication app, string developmentServerBaseUri ) { + app.CheckNull( nameof( app ) ); + AddEndpoints( app ); + if ( app.Environment.IsDevelopment() == false ) + return app; + app.UseSpa( spa => { + spa.Options.SourcePath = SourcePath; + spa.UseProxyToSpaDevelopmentServer( developmentServerBaseUri ); } ); + return app; + } + + /// + /// 使用SPA扩展实现 + /// + private static WebApplication UseCustomSpa( WebApplication app, int port ) { + app.CheckNull( nameof( app ) ); + AddEndpoints( app ); + if ( app.Environment.IsDevelopment() == false ) + return app; + app.UseAngular( spa => { + spa.Options.SourcePath = SourcePath; + spa.Options.DevServerPort = port; + spa.UseAngularCliServer( "start" ); + } ); + return app; } } \ No newline at end of file diff --git a/src/Util.Ui/01-Util.Ui.csproj b/src/Util.Ui/01-Util.Ui.csproj index 98913b7f2..a4130693c 100644 --- a/src/Util.Ui/01-Util.Ui.csproj +++ b/src/Util.Ui/01-Util.Ui.csproj @@ -29,6 +29,7 @@ + diff --git a/src/Util.Ui/Razor/GenerateHtmlFilter.cs b/src/Util.Ui/Razor/GenerateHtmlFilter.cs index 497e770dd..8d9704078 100644 --- a/src/Util.Ui/Razor/GenerateHtmlFilter.cs +++ b/src/Util.Ui/Razor/GenerateHtmlFilter.cs @@ -30,6 +30,11 @@ public async Task OnPageHandlerSelectionAsync( PageHandlerSelectedContext contex var path = CreatePath( context, options ); if ( string.IsNullOrWhiteSpace( path ) ) return; + if ( options.EnableOverrideHtml == false ) { + var filePath = Util.Helpers.Web.GetPhysicalPath( path ); + if ( File.Exists( filePath ) ) + return; + } var log = GetLogger( context ); try { var html = await GetHtml( context ); @@ -37,7 +42,7 @@ public async Task OnPageHandlerSelectionAsync( PageHandlerSelectedContext contex log.LogDebug( $"Razor生成Html成功: Razor Path: {context.ActionDescriptor.ViewEnginePath}, Html Path: {path}" ); } catch ( Exception exception ) { - log.LogError(exception, $"Razor页面生成 html 失败: razor path: {context.ActionDescriptor.ViewEnginePath}" ); + log.LogError( exception, $"Razor页面生成 html 失败: razor path: {context.ActionDescriptor.ViewEnginePath}" ); throw; } } @@ -58,9 +63,7 @@ private string CreatePath( PageHandlerSelectedContext context, RazorOptions opti return GetPath( context?.ActionDescriptor.ViewEnginePath, options.GenerateHtmlBasePath, options.GenerateHtmlFolder, options.GenerateHtmlSuffix ); if ( attribute.Ignore ) return string.Empty; - if ( string.IsNullOrWhiteSpace( attribute.Path ) ) - return string.Empty; - return attribute.Path; + return string.IsNullOrWhiteSpace( attribute.Path ) ? string.Empty : attribute.Path; } /// @@ -70,7 +73,7 @@ private string CreatePath( PageHandlerSelectedContext context, RazorOptions opti /// 基路径,默认值:/ClientApp/src/app /// html文件目录名称,默认值:html /// html文件后缀,默认值:component.html - public static string GetPath( string path, string basePath = "/ClientApp/src/app",string folder = "html", string htmlSuffix = "component.html" ) { + public static string GetPath( string path, string basePath = "/ClientApp/src/app", string folder = "html", string htmlSuffix = "component.html" ) { if ( string.IsNullOrWhiteSpace( path ) ) return string.Empty; path = path.Kebaberize().ToLower().Trim( '\\' ).Trim( '/' ); @@ -115,8 +118,6 @@ private IRazorPage FindPage( IRazorViewEngine razorViewEngine, string pageName ) /// 写入文件 /// private async Task WriteFile( string path, string html ) { - if ( string.IsNullOrWhiteSpace( html ) ) - return; path = Util.Helpers.Web.GetPhysicalPath( path ); var directory = Path.GetDirectoryName( path ); if ( string.IsNullOrWhiteSpace( directory ) ) diff --git a/src/Util.Ui/Razor/HtmlGenerator.cs b/src/Util.Ui/Razor/HtmlGenerator.cs index d6b246aa1..e82e35cab 100644 --- a/src/Util.Ui/Razor/HtmlGenerator.cs +++ b/src/Util.Ui/Razor/HtmlGenerator.cs @@ -14,7 +14,7 @@ public static class HtmlGenerator { /// 取消令牌 public static async Task GenerateAsync( string path, bool isGenerateHtml = true, CancellationToken cancellationToken = default ) { EnableGenerateHtml( isGenerateHtml ); - var requestPath = $"{GetHost()}/view/{path.TrimStart( "/Pages".ToCharArray() ).TrimEnd( ".cshtml".ToCharArray() )}"; + var requestPath = Url.JoinPath( GetHost(), "view", path.RemoveStart( "/Pages" ).RemoveEnd( ".cshtml" ) ); return await Web.Client.Get( requestPath ).GetResultAsync( cancellationToken ); } diff --git a/src/Util.Ui/Razor/Internal/RazorViewContainer.cs b/src/Util.Ui/Razor/Internal/RazorViewContainer.cs index 98673d32f..92d946129 100644 --- a/src/Util.Ui/Razor/Internal/RazorViewContainer.cs +++ b/src/Util.Ui/Razor/Internal/RazorViewContainer.cs @@ -31,10 +31,24 @@ public RazorViewContainer( IPartViewPathResolver partViewPathResolver, IViewCont /// /// 获取全部视图 /// - public List GetAll() { + public List GetAllViews() { return _views.Values.ToList(); } + /// + /// 获取全部主视图 + /// + public List GetMainViews() { + return _views.Values.Where( view => view is MainView ).ToList(); + } + + /// + /// 获取全部主视图路径 + /// + public List GetMainViewPaths() { + return GetMainViews().Select( t => t.Path ).ToList(); + } + /// /// 获取随机视图路径 /// @@ -69,7 +83,7 @@ public void AddView( RazorView view ) { public void Init( IDictionary viewContents ) { if ( viewContents == null ) return; - foreach ( var viewContent in viewContents ) + foreach ( var viewContent in viewContents ) CreateView( viewContent.Key, viewContent.Value ); } diff --git a/src/Util.Ui/Razor/RazorOptions.cs b/src/Util.Ui/Razor/RazorOptions.cs index 5ace853dc..2ca4395df 100644 --- a/src/Util.Ui/Razor/RazorOptions.cs +++ b/src/Util.Ui/Razor/RazorOptions.cs @@ -21,7 +21,23 @@ public class RazorOptions { /// public string GenerateHtmlSuffix { get; set; } = "component.html"; /// + /// 是否启用Razor监视服务,默认值: true + /// + public bool EnableWatchRazor { get; set; } = true; + /// + /// Razor监听服务启动初始化的延迟时间,单位: 毫秒, 默认值:1000, 注意: 需要等待Web服务启动完成才能开始初始化 + /// + public int StartInitDelay { get; set; } = 1000; + /// /// 修改Razor页面生成Html文件的延迟时间,单位: 毫秒, 默认值:100 ,注意: 延迟太短可能导致生成异常 /// - public int HtmlRenderDelayOnRazorChange { get; set; } = 100; + public int HtmlRenderDelayOnRazorChange { get; set; } = 100; + /// + /// 启动Razor监视服务时是否预热,默认值: true + /// + public bool EnablePreheat { get; set; } = true; + /// + /// 启用Razor生成覆盖已存在的html文件,默认值: true + /// + public bool EnableOverrideHtml { get; set; } = true; } \ No newline at end of file diff --git a/src/Util.Ui/Razor/RazorWatchService.cs b/src/Util.Ui/Razor/RazorWatchService.cs index 99fb90684..719b7886d 100644 --- a/src/Util.Ui/Razor/RazorWatchService.cs +++ b/src/Util.Ui/Razor/RazorWatchService.cs @@ -1,5 +1,4 @@ -using System.Net.Http; -using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Configuration; using Util.Helpers; using Util.Ui.Razor.Internal; @@ -9,6 +8,10 @@ namespace Util.Ui.Razor; /// Razor页面监听服务 /// public class RazorWatchService : IRazorWatchService { + /// + /// 是否启动完成 + /// + public static bool IsStartComplete { get; set; } /// /// 文件监视器 /// @@ -33,12 +36,17 @@ public class RazorWatchService : IRazorWatchService { /// Razor配置 /// private readonly RazorOptions _options; + /// + /// 是否生成缺失的html + /// + private bool _isGenerateMissingHtml; /// /// 初始化Razor页面监听服务 /// - public RazorWatchService( IPartViewPathResolver resolver, HttpClient client, + public RazorWatchService( IServiceProvider serviceProvider, IPartViewPathResolver resolver, HttpClient client, IActionDescriptorCollectionProvider provider, IOptions options ) { + Ioc.SetServiceProviderAction( () => serviceProvider ); _watcher = new FileWatcher(); _resolver = resolver; _client = client; @@ -51,15 +59,22 @@ public virtual async Task StartAsync( CancellationToken cancellationToken ) { WriteLog( "启动中..." ); WriteLog( "准备初始化..." ); InitRazorViewContainer(); - await Preheat( cancellationToken ); - WriteLog( "初始化完成." ); - await StartWatch( cancellationToken ); + await Task.Factory.StartNew( async () => { + await Task.Delay( _options.StartInitDelay, cancellationToken ); + await GenerateMissingHtml( cancellationToken ); + await Preheat( cancellationToken ); + IsStartComplete = true; + WriteLog( "初始化完成." ); + await StartWatch( cancellationToken ); + }, cancellationToken ); } /// /// 写入控制台日志 /// - private void WriteLog( string content ) { + private void WriteLog( string content, bool isWrite = true ) { + if ( isWrite == false ) + return; Console.WriteLine( $"dbug: Util应用框架 - Razor监听服务 - {content}" ); } @@ -95,22 +110,58 @@ protected List GetPageActionDescriptors() { /// 获取Razor文件内容 /// protected string GetContent( string relativePath ) { - var path = Url.JoinPath( Common.GetCurrentDirectory(), relativePath ); + var path = GetProjectPath( relativePath ); return Util.Helpers.File.ReadToString( path ); } /// - /// Razor页面预热 + /// 获取项目路径 /// - protected async Task Preheat( CancellationToken cancellationToken ) { - await Task.Factory.StartNew( async () => { - await Task.Delay( 1000, cancellationToken ); - WriteLog( "Razor页面准备预热..." ); - var paths = _container.GetRandomPaths(); - foreach ( var path in paths ) - await Request( path, cancellationToken ); - WriteLog( "Razor页面预热完成..." ); - }, cancellationToken ); + protected string GetProjectPath( string relativePath ) { + return Url.JoinPath( Common.GetCurrentDirectory(), relativePath ); + } + + /// + /// 生成缺失的html + /// + protected async Task GenerateMissingHtml( CancellationToken cancellationToken ) { + WriteLog( "准备生成缺失的html..." ); + EnableGenerateHtml(); + EnableOverrideHtml( false ); + var fileBasePath = GetProjectPath( _options.GenerateHtmlBasePath ); + var files = Util.Helpers.File.GetAllFiles( fileBasePath, "*.html" ); + foreach ( var path in _container.GetMainViewPaths() ) { + if ( Exists( files.Select( t => t.FullName ).ToList(), path ) ) + continue; + _isGenerateMissingHtml = true; + await Request( path, cancellationToken ); + } + EnableOverrideHtml(); + WriteLog( "生成缺失的html完成..." ); + } + + /// + /// 启用Html生成 + /// + protected void EnableGenerateHtml( bool isGenerateHtml = true ) { + _options.IsGenerateHtml = isGenerateHtml; + } + + /// + /// 启用Html覆盖 + /// + protected void EnableOverrideHtml( bool isOverrideHtml = true ) { + _options.EnableOverrideHtml = isOverrideHtml; + } + + /// + /// html是否存在 + /// + protected bool Exists( List htmlPaths, string razorPath ) { + if ( string.Equals( razorPath, "/Pages/Error.cshtml", StringComparison.OrdinalIgnoreCase ) ) + return true; + var path = GenerateHtmlFilter.GetPath( razorPath.RemoveStart( "/Pages" ).RemoveEnd( ".cshtml" ) ); + return htmlPaths.Any( t => t.Replace( "\\", "/" ).EndsWith( path, StringComparison.OrdinalIgnoreCase ) ); } /// @@ -118,17 +169,21 @@ await Task.Factory.StartNew( async () => { /// /// 视图路径 /// 取消令牌 - public async Task Request( string path, CancellationToken cancellationToken = default ) { + /// 是否写入日志 + public async Task Request( string path, CancellationToken cancellationToken = default, bool isWrite = true ) { await Task.Delay( _options.HtmlRenderDelayOnRazorChange, cancellationToken ); - var requestPath = $"{GetApplicationUrl()}/view/{path.TrimStart( "/Pages".ToCharArray() ).TrimEnd( ".cshtml".ToCharArray() )}"; - await _client.GetAsync( requestPath, cancellationToken ); + var requestPath = Url.JoinPath( GetApplicationUrl(), "view", path.RemoveStart( "/Pages" ).RemoveEnd( ".cshtml" ) ); + WriteLog( $"发送请求: {requestPath}", isWrite ); + var response = await _client.GetAsync( requestPath, cancellationToken ); + if ( response.IsSuccessStatusCode ) + WriteLog( $"请求成功: {requestPath}", isWrite ); } /// /// 获取应用地址 /// protected string GetApplicationUrl() { - var path = Url.JoinPath( Common.GetCurrentDirectory(), "Properties" ); + var path = GetProjectPath( "Properties" ); var builder = new ConfigurationBuilder() .SetBasePath( path ) .AddJsonFile( "launchSettings.json", true, false ); @@ -140,12 +195,28 @@ protected string GetApplicationUrl() { return null; } + /// + /// Razor页面预热 + /// + protected async Task Preheat( CancellationToken cancellationToken ) { + if ( _isGenerateMissingHtml ) + return; + if ( _options.EnablePreheat == false ) + return; + WriteLog( "Razor页面准备预热..." ); + EnableGenerateHtml( false ); + var paths = _container.GetRandomPaths(); + foreach ( var path in paths ) + await Request( path, cancellationToken, false ); + WriteLog( "Razor页面预热完成..." ); + } + /// /// 启动监听器 /// protected virtual Task StartWatch( CancellationToken cancellationToken ) { WriteLog( "开始监听..." ); - var path = Common.JoinPath( Common.GetCurrentDirectory(), "Pages" ); + var path = GetProjectPath( "Pages" ); _watcher.Path( path ) .Filter( "*.cshtml" ) .OnChangedAsync( async ( _, e ) => await GenerateAsync( e.FullPath, cancellationToken ) ) @@ -169,10 +240,8 @@ private async Task GenerateAsync( string fullPath, CancellationToken cancellatio WriteLog( $"未找到可更新的视图路径: {path}" ); return; } - foreach ( var viewPath in viewPaths ) { - WriteLog( $"请求生成: {path}" ); + foreach ( var viewPath in viewPaths ) await Request( viewPath, cancellationToken ); - } } /// @@ -182,13 +251,6 @@ protected bool IsCshtml( string path ) { return path.EndsWith( ".cshtml", StringComparison.OrdinalIgnoreCase ); } - /// - /// 启用Html生成 - /// - protected void EnableGenerateHtml() { - _options.IsGenerateHtml = true; - } - /// public virtual Task StopAsync( CancellationToken cancellationToken ) { WriteLog( "准备关闭服务..." ); diff --git a/src/Util.Ui/Sources/Spa/AngularCli/AngularCliBuilder.cs b/src/Util.Ui/Sources/Spa/AngularCli/AngularCliBuilder.cs new file mode 100644 index 000000000..c76131441 --- /dev/null +++ b/src/Util.Ui/Sources/Spa/AngularCli/AngularCliBuilder.cs @@ -0,0 +1,86 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; +using Microsoft.Extensions.Hosting; +using Util.Ui.Sources.Spa.Npm; +using Util.Ui.Sources.Spa.Prerendering; +using Util.Ui.Sources.Spa.Util; + +namespace Util.Ui.Sources.Spa.AngularCli; + +/// +/// Provides an implementation of that can build +/// an Angular application by invoking the Angular CLI. +/// +[Obsolete("Prerendering is no longer supported out of box")] +public class AngularCliBuilder : ISpaPrerendererBuilder +{ + private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(5); // This is a development-time only feature, so a very long timeout is fine + + private readonly string _scriptName; + + /// + /// Constructs an instance of . + /// + /// The name of the script in your package.json file that builds the server-side bundle for your Angular application. + public AngularCliBuilder(string npmScript) + { + if (string.IsNullOrEmpty(npmScript)) + { + throw new ArgumentException("Cannot be null or empty.", nameof(npmScript)); + } + + _scriptName = npmScript; + } + + /// + public async Task Build(ISpaBuilder spaBuilder) + { + var pkgManagerCommand = spaBuilder.Options.PackageManagerCommand; + var sourcePath = spaBuilder.Options.SourcePath; + if (string.IsNullOrEmpty(sourcePath)) + { + throw new InvalidOperationException($"To use {nameof(AngularCliBuilder)}, you must supply a non-empty value for the {nameof(SpaOptions.SourcePath)} property of {nameof(SpaOptions)} ."); + } + + var appBuilder = spaBuilder.ApplicationBuilder; + var applicationStoppingToken = appBuilder.ApplicationServices.GetRequiredService().ApplicationStopping; + var logger = LoggerFinder.GetOrCreateLogger( + appBuilder, + nameof(AngularCliBuilder)); + var diagnosticSource = appBuilder.ApplicationServices.GetRequiredService(); + var scriptRunner = new NodeScriptRunner( + sourcePath, + _scriptName, + "--watch", + null, + pkgManagerCommand, + diagnosticSource, + applicationStoppingToken); + scriptRunner.AttachToLogger(logger); + + using (var stdOutReader = new EventedStreamStringReader(scriptRunner.StdOut)) + using (var stdErrReader = new EventedStreamStringReader(scriptRunner.StdErr)) + { + try + { + await scriptRunner.StdOut.WaitForMatch( ["Date"] ); + } + catch (EndOfStreamException ex) + { + throw new InvalidOperationException( + $"The {pkgManagerCommand} script '{_scriptName}' exited without indicating success.\n" + + $"Output was: {stdOutReader.ReadAsString()}\n" + + $"Error output was: {stdErrReader.ReadAsString()}", ex); + } + catch (OperationCanceledException ex) + { + throw new InvalidOperationException( + $"The {pkgManagerCommand} script '{_scriptName}' timed out without indicating success. " + + $"Output was: {stdOutReader.ReadAsString()}\n" + + $"Error output was: {stdErrReader.ReadAsString()}", ex); + } + } + } +} diff --git a/src/Util.Ui/Sources/Spa/AngularCli/AngularCliMiddleware.cs b/src/Util.Ui/Sources/Spa/AngularCli/AngularCliMiddleware.cs new file mode 100644 index 000000000..b7fc8f8ff --- /dev/null +++ b/src/Util.Ui/Sources/Spa/AngularCli/AngularCliMiddleware.cs @@ -0,0 +1,112 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Util.Ui.Razor; +using Util.Ui.Sources.Spa.Npm; +using Util.Ui.Sources.Spa.Proxying; +using Util.Ui.Sources.Spa.Util; + +namespace Util.Ui.Sources.Spa.AngularCli; + +internal static class AngularCliMiddleware { + private const string LogCategoryName = "Microsoft.AspNetCore.SpaServices"; + private static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds( 5 ); // This is a development-time only feature, so a very long timeout is fine + + public static void Attach( + ISpaBuilder spaBuilder, + string scriptName ) { + var pkgManagerCommand = spaBuilder.Options.PackageManagerCommand; + var sourcePath = spaBuilder.Options.SourcePath; + var devServerPort = spaBuilder.Options.DevServerPort; + if ( string.IsNullOrEmpty( sourcePath ) ) { + throw new ArgumentException( "Property 'SourcePath' cannot be null or empty", nameof( spaBuilder ) ); + } + + if ( string.IsNullOrEmpty( scriptName ) ) { + throw new ArgumentException( "Cannot be null or empty", nameof( scriptName ) ); + } + + if ( devServerPort == default ) { + devServerPort = TcpPortFinder.FindAvailablePort(); + } + + // Start Angular CLI and attach to middleware pipeline + var appBuilder = spaBuilder.ApplicationBuilder; + var applicationStoppingToken = appBuilder.ApplicationServices.GetRequiredService().ApplicationStopping; + var logger = LoggerFinder.GetOrCreateLogger( appBuilder, LogCategoryName ); + var diagnosticSource = appBuilder.ApplicationServices.GetRequiredService(); + var angularCliServerInfoTask = GetUrl( devServerPort, applicationStoppingToken ); + spaBuilder.UseProxyToSpaDevelopmentServer( () => { + var timeout = spaBuilder.Options.StartupTimeout; + return angularCliServerInfoTask.WithTimeout( timeout, + $"The Angular CLI process did not start listening for requests " + + $"within the timeout period of {timeout.TotalSeconds} seconds. " + + $"Check the log output for error information." ); + } ); + Task.Factory.StartNew( async () => { + while ( true ) { + if ( RazorWatchService.IsStartComplete == false ) { + await Task.Delay( 500, applicationStoppingToken ); + continue; + } + await StartAngularCliServerAsync( sourcePath, scriptName, pkgManagerCommand, devServerPort, logger, diagnosticSource, applicationStoppingToken ); + return; + } + }, applicationStoppingToken ); + } + + private static async Task GetUrl( int portNumber, CancellationToken applicationStoppingToken ) { + var uri = new Uri( $"http://localhost:{portNumber}" ); + await WaitForAngularCliServerToAcceptRequests( uri, applicationStoppingToken ); + return uri; + } + + private static async Task StartAngularCliServerAsync( string sourcePath, string scriptName, string pkgManagerCommand, int portNumber, ILogger logger, DiagnosticSource diagnosticSource, CancellationToken applicationStoppingToken ) { + Console.WriteLine($"dbug: ׼Angular http://localhost:{portNumber}"); + if ( logger.IsEnabled( LogLevel.Information ) ) { + logger.LogInformation( $"Starting @angular/cli on port {portNumber}..." ); + } + + var scriptRunner = new NodeScriptRunner( + sourcePath, scriptName, $"--port {portNumber}", null, pkgManagerCommand, diagnosticSource, applicationStoppingToken ); + scriptRunner.AttachToLogger( logger ); + + bool openBrowserLine; + using var stdErrReader = new EventedStreamStringReader( scriptRunner.StdErr ); + try { + openBrowserLine = await scriptRunner.StdOut.WaitForMatch( ["Watch mode enabled", "Angular Live Development"] ); + } + catch ( EndOfStreamException ex ) { + throw new InvalidOperationException( + $"The {pkgManagerCommand} script '{scriptName}' exited without indicating that the " + + $"Angular CLI was listening for requests. The error output was: " + + $"{stdErrReader.ReadAsString()}", ex ); + } + } + + private static async Task WaitForAngularCliServerToAcceptRequests( Uri cliServerUri, CancellationToken applicationStoppingToken ) { + using var client = new HttpClient(); + var i = 0; + while ( true ) { + try { + i++; + var response = await client.SendAsync( new HttpRequestMessage( HttpMethod.Get, cliServerUri ), applicationStoppingToken ); + var content = await response.Content.ReadAsStringAsync( applicationStoppingToken ); + if ( content.IsEmpty() == false ) { + return; + } + await Task.Delay( 300, applicationStoppingToken ); + if ( i > 1000 ) { + return; + } + } + catch ( Exception ) { + // ignored + } + } + } +} diff --git a/src/Util.Ui/Sources/Spa/AngularCli/AngularCliMiddlewareExtensions.cs b/src/Util.Ui/Sources/Spa/AngularCli/AngularCliMiddlewareExtensions.cs new file mode 100644 index 000000000..e844569ad --- /dev/null +++ b/src/Util.Ui/Sources/Spa/AngularCli/AngularCliMiddlewareExtensions.cs @@ -0,0 +1,25 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Util.Ui.Sources.Spa.AngularCli; + +/// +/// Extension methods for enabling Angular CLI middleware support. +/// +public static class AngularCliMiddlewareExtensions { + /// + /// ʹAngular + /// + public static void UseAngularCliServer( + this ISpaBuilder spaBuilder, + string npmScript ) { + ArgumentNullException.ThrowIfNull( spaBuilder ); + + var spaOptions = spaBuilder.Options; + + if ( string.IsNullOrEmpty( spaOptions.SourcePath ) ) { + throw new InvalidOperationException( $"To use {nameof( UseAngularCliServer )}, you must supply a non-empty value for the {nameof( SpaOptions.SourcePath )} property of {nameof( SpaOptions )} ." ); + } + AngularCliMiddleware.Attach( spaBuilder, npmScript ); + } +} diff --git a/src/Util.Ui/Sources/Spa/DefaultSpaBuilder.cs b/src/Util.Ui/Sources/Spa/DefaultSpaBuilder.cs new file mode 100644 index 000000000..522001091 --- /dev/null +++ b/src/Util.Ui/Sources/Spa/DefaultSpaBuilder.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Util.Ui.Sources.Spa; + +internal sealed class DefaultSpaBuilder : ISpaBuilder +{ + public IApplicationBuilder ApplicationBuilder { get; } + + public SpaOptions Options { get; } + + public DefaultSpaBuilder(IApplicationBuilder applicationBuilder, SpaOptions options) + { + ApplicationBuilder = applicationBuilder + ?? throw new ArgumentNullException(nameof(applicationBuilder)); + + Options = options + ?? throw new ArgumentNullException(nameof(options)); + } +} diff --git a/src/Util.Ui/Sources/Spa/ISpaBuilder.cs b/src/Util.Ui/Sources/Spa/ISpaBuilder.cs new file mode 100644 index 000000000..aeb5df0d9 --- /dev/null +++ b/src/Util.Ui/Sources/Spa/ISpaBuilder.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Util.Ui.Sources.Spa; + +/// +/// Defines a class that provides mechanisms for configuring the hosting +/// of a Single Page Application (SPA) and attaching middleware. +/// +public interface ISpaBuilder +{ + /// + /// The representing the middleware pipeline + /// in which the SPA is being hosted. + /// + IApplicationBuilder ApplicationBuilder { get; } + + /// + /// Describes configuration options for hosting a SPA. + /// + SpaOptions Options { get; } +} diff --git a/src/Util.Ui/Sources/Spa/Npm/NodeScriptRunner.cs b/src/Util.Ui/Sources/Spa/Npm/NodeScriptRunner.cs new file mode 100644 index 000000000..cd330459a --- /dev/null +++ b/src/Util.Ui/Sources/Spa/Npm/NodeScriptRunner.cs @@ -0,0 +1,163 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Text.RegularExpressions; +using Microsoft.Extensions.Logging; +using Util.Ui.Sources.Spa.Util; + +// This is under the NodeServices namespace because post 2.1 it will be moved to that package +namespace Util.Ui.Sources.Spa.Npm; + +/// +/// Executes the script entries defined in a package.json file, +/// capturing any output written to stdio. +/// +internal sealed class NodeScriptRunner : IDisposable +{ + private Process _npmProcess; + public EventedStreamReader StdOut { get; } + public EventedStreamReader StdErr { get; } + + private static readonly Regex AnsiColorRegex = new Regex("\x001b\\[[0-9;]*m", RegexOptions.None, TimeSpan.FromSeconds(1)); + + public NodeScriptRunner(string workingDirectory, string scriptName, string arguments, IDictionary envVars, string pkgManagerCommand, DiagnosticSource diagnosticSource, CancellationToken applicationStoppingToken) + { + if (string.IsNullOrEmpty(workingDirectory)) + { + throw new ArgumentException("Cannot be null or empty.", nameof(workingDirectory)); + } + + if (string.IsNullOrEmpty(scriptName)) + { + throw new ArgumentException("Cannot be null or empty.", nameof(scriptName)); + } + + if (string.IsNullOrEmpty(pkgManagerCommand)) + { + throw new ArgumentException("Cannot be null or empty.", nameof(pkgManagerCommand)); + } + + var exeToRun = pkgManagerCommand; + var completeArguments = $"run {scriptName} -- {arguments ?? string.Empty}"; + if (OperatingSystem.IsWindows()) + { + // On Windows, the node executable is a .cmd file, so it can't be executed + // directly (except with UseShellExecute=true, but that's no good, because + // it prevents capturing stdio). So we need to invoke it via "cmd /c". + exeToRun = "cmd"; + completeArguments = $"/c {pkgManagerCommand} {completeArguments}"; + } + + var processStartInfo = new ProcessStartInfo(exeToRun) + { + Arguments = completeArguments, + UseShellExecute = false, + RedirectStandardInput = true, + RedirectStandardOutput = true, + RedirectStandardError = true, + WorkingDirectory = workingDirectory + }; + + if (envVars != null) + { + foreach (var keyValuePair in envVars) + { + processStartInfo.Environment[keyValuePair.Key] = keyValuePair.Value; + } + } + + _npmProcess = LaunchNodeProcess(processStartInfo, pkgManagerCommand); + StdOut = new EventedStreamReader(_npmProcess.StandardOutput); + StdErr = new EventedStreamReader(_npmProcess.StandardError); + + applicationStoppingToken.Register(((IDisposable)this).Dispose); + + if (diagnosticSource.IsEnabled("Microsoft.AspNetCore.NodeServices.Npm.NpmStarted")) + { + WriteDiagnosticEvent( + diagnosticSource, + "Microsoft.AspNetCore.NodeServices.Npm.NpmStarted", + new + { + processStartInfo = processStartInfo, + process = _npmProcess + }); + } + + [UnconditionalSuppressMessage("ReflectionAnalysis", "IL2026", + Justification = "The values being passed into Write have the commonly used properties being preserved with DynamicDependency.")] + static void WriteDiagnosticEvent<[DynamicallyAccessedMembers(DynamicallyAccessedMemberTypes.PublicProperties)] TValue>(DiagnosticSource diagnosticSource, string name, TValue value) + => diagnosticSource.Write(name, value); + } + + public void AttachToLogger(ILogger logger) + { + // When the node task emits complete lines, pass them through to the real logger + StdOut.OnReceivedLine += line => + { + if (!string.IsNullOrWhiteSpace(line) && logger.IsEnabled(LogLevel.Information)) + { + // Node tasks commonly emit ANSI colors, but it wouldn't make sense to forward + // those to loggers (because a logger isn't necessarily any kind of terminal) + logger.LogInformation(StripAnsiColors(line)); + } + }; + + StdErr.OnReceivedLine += line => + { + if (!string.IsNullOrWhiteSpace(line)) + { + logger.LogWarning(StripAnsiColors(line)); + } + }; + + // But when it emits incomplete lines, assume this is progress information and + // hence just pass it through to StdOut regardless of logger config. + StdErr.OnReceivedChunk += chunk => + { + Debug.Assert(chunk.Array != null); + + var containsNewline = Array.IndexOf( + chunk.Array, '\n', chunk.Offset, chunk.Count) >= 0; + if (!containsNewline) + { + Console.Write(chunk.Array, chunk.Offset, chunk.Count); + } + }; + } + + private static string StripAnsiColors(string line) + => AnsiColorRegex.Replace(line, string.Empty); + + private static Process LaunchNodeProcess(ProcessStartInfo startInfo, string commandName) + { + try + { + var process = Process.Start(startInfo)!; + + // See equivalent comment in OutOfProcessNodeInstance.cs for why + process.EnableRaisingEvents = true; + + return process; + } + catch (Exception ex) + { + var message = $"Failed to start '{commandName}'. To resolve this:.\n\n" + + $"[1] Ensure that '{commandName}' is installed and can be found in one of the PATH directories.\n" + + $" Current PATH enviroment variable is: { Environment.GetEnvironmentVariable("PATH") }\n" + + " Make sure the executable is in one of those directories, or update your PATH.\n\n" + + "[2] See the InnerException for further details of the cause."; + throw new InvalidOperationException(message, ex); + } + } + + void IDisposable.Dispose() + { + if (_npmProcess != null && !_npmProcess.HasExited) + { + _npmProcess.Kill(entireProcessTree: true); + _npmProcess = null; + } + } +} diff --git a/src/Util.Ui/Sources/Spa/Prerendering/ISpaPrerendererBuilder.cs b/src/Util.Ui/Sources/Spa/Prerendering/ISpaPrerendererBuilder.cs new file mode 100644 index 000000000..c9488694c --- /dev/null +++ b/src/Util.Ui/Sources/Spa/Prerendering/ISpaPrerendererBuilder.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Util.Ui.Sources.Spa.Prerendering; + +/// +/// Represents the ability to build a Single Page Application (SPA) on demand +/// so that it can be prerendered. This is only intended to be used at development +/// time. In production, a SPA should already have been built during publishing. +/// +[Obsolete("Prerendering is no longer supported out of box")] +public interface ISpaPrerendererBuilder +{ + /// + /// Builds the Single Page Application so that a JavaScript entrypoint file + /// exists on disk. Prerendering middleware can then execute that file in + /// a Node environment. + /// + /// The . + /// A representing completion of the build process. + Task Build(ISpaBuilder spaBuilder); +} diff --git a/src/Util.Ui/Sources/Spa/Proxying/ConditionalProxyMiddleware.cs b/src/Util.Ui/Sources/Spa/Proxying/ConditionalProxyMiddleware.cs new file mode 100644 index 000000000..825d2988c --- /dev/null +++ b/src/Util.Ui/Sources/Spa/Proxying/ConditionalProxyMiddleware.cs @@ -0,0 +1,62 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Hosting; + +namespace Util.Ui.Sources.Spa.Proxying; + +// This duplicates and updates the proxying logic in SpaServices so that we can update +// the project templates without waiting for 2.1 to ship. When 2.1 is ready to ship, +// merge the additional proxying features (e.g., proxying websocket connections) back +// into the SpaServices proxying code. It's all internal. +internal sealed class ConditionalProxyMiddleware +{ + private readonly RequestDelegate _next; + private readonly Task _baseUriTask; + private readonly string _pathPrefix; + private readonly bool _pathPrefixIsRoot; + private readonly HttpClient _httpClient; + private readonly CancellationToken _applicationStoppingToken; + + public ConditionalProxyMiddleware( + RequestDelegate next, + string pathPrefix, + TimeSpan requestTimeout, + Task baseUriTask, + IHostApplicationLifetime applicationLifetime) + { + if (!pathPrefix.StartsWith('/')) + { + pathPrefix = "/" + pathPrefix; + } + + _next = next; + _pathPrefix = pathPrefix; + _pathPrefixIsRoot = string.Equals(_pathPrefix, "/", StringComparison.Ordinal); + _baseUriTask = baseUriTask; + _httpClient = SpaProxy.CreateHttpClientForProxy(requestTimeout); + _applicationStoppingToken = applicationLifetime.ApplicationStopping; + } + + public Task Invoke(HttpContext context) + { + if (context.Request.Path.StartsWithSegments(_pathPrefix) || _pathPrefixIsRoot) + { + return InvokeCore(context); + } + return _next.Invoke(context); + } + + private async Task InvokeCore(HttpContext context) + { + var didProxyRequest = await SpaProxy.PerformProxyRequest( + context, _httpClient, _baseUriTask, _applicationStoppingToken, proxy404s: false); + if (didProxyRequest) + { + return; + } + + // Not a request we can proxy + await _next.Invoke(context); + } +} diff --git a/src/Util.Ui/Sources/Spa/Proxying/SpaProxy.cs b/src/Util.Ui/Sources/Spa/Proxying/SpaProxy.cs new file mode 100644 index 000000000..0b379c775 --- /dev/null +++ b/src/Util.Ui/Sources/Spa/Proxying/SpaProxy.cs @@ -0,0 +1,304 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Net.WebSockets; + +namespace Util.Ui.Sources.Spa.Proxying; + +// This duplicates and updates the proxying logic in SpaServices so that we can update +// the project templates without waiting for 2.1 to ship. When 2.1 is ready to ship, +// remove the old ConditionalProxy.cs from SpaServices and replace its usages with this. +// Doesn't affect public API surface - it's all internal. +internal static class SpaProxy +{ + private const int DefaultWebSocketBufferSize = 4096; + private const int StreamCopyBufferSize = 81920; + + // https://github.com/dotnet/aspnetcore/issues/16797 + private static readonly HashSet NotForwardedHttpHeaders = new HashSet( + new[] { "Connection" }, + StringComparer.OrdinalIgnoreCase + ); + + // Don't forward User-Agent/Accept because of https://github.com/aspnet/JavaScriptServices/issues/1469 + // Others just aren't applicable in proxy scenarios + private static readonly HashSet NotForwardedWebSocketHeaders = new HashSet( + new[] { "Accept", "Connection", "Host", "User-Agent", "Upgrade", "Sec-WebSocket-Key", "Sec-WebSocket-Protocol", "Sec-WebSocket-Version" }, + StringComparer.OrdinalIgnoreCase + ); + + // In case the connection to the client is HTTP/2 or HTTP/3 and to the server HTTP/1.1 or less, let's get rid of the HTTP/1.1 only headers + private static readonly HashSet InvalidH2H3Headers = new HashSet( + new[] { "Connection", "Transfer-Encoding", "Keep-Alive", "Upgrade", "Proxy-Connection" }, + StringComparer.OrdinalIgnoreCase + ); + + public static HttpClient CreateHttpClientForProxy(TimeSpan requestTimeout) + { + var handler = new HttpClientHandler + { + AllowAutoRedirect = false, + UseCookies = false, + }; + + return new HttpClient(handler) + { + Timeout = requestTimeout + }; + } + + public static async Task PerformProxyRequest( + HttpContext context, + HttpClient httpClient, + Task baseUriTask, + CancellationToken applicationStoppingToken, + bool proxy404s) + { + // Stop proxying if either the server or client wants to disconnect + var proxyCancellationToken = CancellationTokenSource.CreateLinkedTokenSource( + context.RequestAborted, + applicationStoppingToken).Token; + + // We allow for the case where the target isn't known ahead of time, and want to + // delay proxied requests until the target becomes known. This is useful, for example, + // when proxying to Angular CLI middleware: we won't know what port it's listening + // on until it finishes starting up. + var baseUri = await baseUriTask; + var baseUriAsString = baseUri.ToString(); + var targetUri = new Uri((baseUriAsString.EndsWith("/", StringComparison.OrdinalIgnoreCase) ? baseUriAsString[..^1] : baseUriAsString) + + context.Request.Path + + context.Request.QueryString); + + try + { + if (context.WebSockets.IsWebSocketRequest) + { + await AcceptProxyWebSocketRequest(context, ToWebSocketScheme(targetUri), proxyCancellationToken); + return true; + } + else { + using var requestMessage = CreateProxyHttpRequest(context, targetUri); + using var responseMessage = await httpClient.SendAsync( + requestMessage, + HttpCompletionOption.ResponseHeadersRead, + proxyCancellationToken); + if (!proxy404s) + { + if (responseMessage.StatusCode == HttpStatusCode.NotFound) + { + // We're not proxying 404s, i.e., we want to resume the middleware pipeline + // and let some other middleware handle this. + return false; + } + } + + await CopyProxyHttpResponse(context, responseMessage, proxyCancellationToken); + return true; + } + } + catch (OperationCanceledException) + { + // If we're aborting because either the client disconnected, or the server + // is shutting down, don't treat this as an error. + return true; + } + catch (IOException) + { + // This kind of exception can also occur if a proxy read/write gets interrupted + // due to the process shutting down. + return true; + } + catch (HttpRequestException ex) + { + throw new HttpRequestException( + $"Failed to proxy the request to {targetUri.ToString()}, because the request to " + + $"the proxy target failed. Check that the proxy target server is running and " + + $"accepting requests to {baseUri.ToString()}.\n\n" + + $"The underlying exception message was '{ex.Message}'." + + $"Check the InnerException for more details.", ex); + } + } + + private static HttpRequestMessage CreateProxyHttpRequest(HttpContext context, Uri uri) + { + var request = context.Request; + + var requestMessage = new HttpRequestMessage(); + var requestMethod = request.Method; + if (!HttpMethods.IsGet(requestMethod) && + !HttpMethods.IsHead(requestMethod) && + !HttpMethods.IsDelete(requestMethod) && + !HttpMethods.IsTrace(requestMethod)) + { + var streamContent = new StreamContent(request.Body); + requestMessage.Content = streamContent; + } + + // Copy the request headers + foreach (var header in request.Headers) + { + if (NotForwardedHttpHeaders.Contains(header.Key)) + { + continue; + } + + if (!requestMessage.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()) && requestMessage.Content != null) + { + requestMessage.Content?.Headers.TryAddWithoutValidation(header.Key, header.Value.ToArray()); + } + } + + requestMessage.Headers.Host = uri.Authority; + requestMessage.RequestUri = uri; + requestMessage.Method = new HttpMethod(request.Method); + + return requestMessage; + } + + private static async Task CopyProxyHttpResponse(HttpContext context, HttpResponseMessage responseMessage, CancellationToken cancellationToken) + { + context.Response.StatusCode = (int)responseMessage.StatusCode; + foreach (var header in responseMessage.Headers) + { + if ((HttpProtocol.IsHttp2(context.Request.Protocol) || HttpProtocol.IsHttp3(context.Request.Protocol)) + && InvalidH2H3Headers.Contains(header.Key)) + { + continue; + } + context.Response.Headers[header.Key] = header.Value.ToArray(); + } + + foreach (var header in responseMessage.Content.Headers) + { + context.Response.Headers[header.Key] = header.Value.ToArray(); + } + + // SendAsync removes chunking from the response. This removes the header so it doesn't expect a chunked response. + context.Response.Headers.Remove("transfer-encoding"); + + using (var responseStream = await responseMessage.Content.ReadAsStreamAsync(cancellationToken)) + { + await responseStream.CopyToAsync(context.Response.Body, StreamCopyBufferSize, cancellationToken); + } + } + + private static Uri ToWebSocketScheme(Uri uri) + { + ArgumentNullException.ThrowIfNull(uri); + + var uriBuilder = new UriBuilder(uri); + if (string.Equals(uriBuilder.Scheme, "https", StringComparison.OrdinalIgnoreCase)) + { + uriBuilder.Scheme = "wss"; + } + else if (string.Equals(uriBuilder.Scheme, "http", StringComparison.OrdinalIgnoreCase)) + { + uriBuilder.Scheme = "ws"; + } + + return uriBuilder.Uri; + } + + private static async Task AcceptProxyWebSocketRequest(HttpContext context, Uri destinationUri, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentNullException.ThrowIfNull(destinationUri); + + using (var client = new ClientWebSocket()) + { + foreach (var protocol in context.WebSockets.WebSocketRequestedProtocols) + { + client.Options.AddSubProtocol(protocol); + } + foreach (var headerEntry in context.Request.Headers) + { + if (!NotForwardedWebSocketHeaders.Contains(headerEntry.Key)) + { + try + { + client.Options.SetRequestHeader(headerEntry.Key, headerEntry.Value); + } + catch (ArgumentException) + { + // On net462, certain header names are reserved and can't be set. + // We filter out the known ones via the test above, but there could + // be others arbitrarily set by the client. It's not helpful to + // consider it an error, so just skip non-forwardable headers. + // The perf implications of handling this via a catch aren't an + // issue since this is a dev-time only feature. + } + } + } + + try + { + // Note that this is not really good enough to make Websockets work with + // Angular CLI middleware. For some reason, ConnectAsync takes over 1 second, + // on Windows, by which time the logic in SockJS has already timed out and made + // it fall back on some other transport (xhr_streaming, usually). It's fine + // on Linux though, completing almost instantly. + // + // The slowness on Windows does not cause a problem though, because the transport + // fallback logic works correctly and doesn't surface any errors, but it would be + // better if ConnectAsync was fast enough and the initial Websocket transport + // could actually be used. + await client.ConnectAsync(destinationUri, cancellationToken); + } + catch (WebSocketException) + { + context.Response.StatusCode = 400; + return false; + } + + using var server = await context.WebSockets.AcceptWebSocketAsync(client.SubProtocol); + var bufferSize = DefaultWebSocketBufferSize; + await Task.WhenAll( + PumpWebSocket(client, server, bufferSize, cancellationToken), + PumpWebSocket(server, client, bufferSize, cancellationToken)); + + return true; + } + } + + private static async Task PumpWebSocket(WebSocket source, WebSocket destination, int bufferSize, CancellationToken cancellationToken) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(bufferSize); + + var buffer = new byte[bufferSize]; + + while (true) + { + // Because WebSocket.ReceiveAsync doesn't work well with CancellationToken (it doesn't + // actually exit when the token notifies, at least not in the 'server' case), use + // polling. The perf might not be ideal, but this is a dev-time feature only. + var resultTask = source.ReceiveAsync(new ArraySegment(buffer), cancellationToken); + while (true) + { + if (cancellationToken.IsCancellationRequested) + { + return; + } + + if (resultTask.IsCompleted) + { + break; + } + + await Task.Delay(100, cancellationToken); + } + + var result = resultTask.Result; // We know it's completed already + if (result.MessageType == WebSocketMessageType.Close) + { + if (destination.State == WebSocketState.Open || destination.State == WebSocketState.CloseReceived) + { + await destination.CloseOutputAsync(source.CloseStatus!.Value, source.CloseStatusDescription, cancellationToken); + } + + return; + } + + await destination.SendAsync(new ArraySegment(buffer, 0, result.Count), result.MessageType, result.EndOfMessage, cancellationToken); + } + } +} diff --git a/src/Util.Ui/Sources/Spa/Proxying/SpaProxyingExtensions.cs b/src/Util.Ui/Sources/Spa/Proxying/SpaProxyingExtensions.cs new file mode 100644 index 000000000..281b2036d --- /dev/null +++ b/src/Util.Ui/Sources/Spa/Proxying/SpaProxyingExtensions.cs @@ -0,0 +1,84 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Http.Extensions; +using Microsoft.Extensions.Hosting; + +namespace Util.Ui.Sources.Spa.Proxying; + +/// +/// Extension methods for proxying requests to a local SPA development server during +/// development. Not for use in production applications. +/// +public static class SpaProxyingExtensions { + /// + /// Configures the application to forward incoming requests to a local Single Page + /// Application (SPA) development server. This is only intended to be used during + /// development. Do not enable this middleware in production applications. + /// + /// The . + /// The target base URI to which requests should be proxied. + public static void UseProxyToSpaDevelopmentServer( + this ISpaBuilder spaBuilder, + string baseUri ) { + UseProxyToSpaDevelopmentServer( + spaBuilder, + new Uri( baseUri ) ); + } + + /// + /// Configures the application to forward incoming requests to a local Single Page + /// Application (SPA) development server. This is only intended to be used during + /// development. Do not enable this middleware in production applications. + /// + /// The . + /// The target base URI to which requests should be proxied. + public static void UseProxyToSpaDevelopmentServer( + this ISpaBuilder spaBuilder, + Uri baseUri ) { + UseProxyToSpaDevelopmentServer( + spaBuilder, + () => Task.FromResult( baseUri ) ); + } + + /// + /// Configures the application to forward incoming requests to a local Single Page + /// Application (SPA) development server. This is only intended to be used during + /// development. Do not enable this middleware in production applications. + /// + /// The . + /// A callback that will be invoked on each request to supply a that resolves with the target base URI to which requests should be proxied. + public static void UseProxyToSpaDevelopmentServer( + this ISpaBuilder spaBuilder, + Func> baseUriTaskFactory ) { + var applicationBuilder = spaBuilder.ApplicationBuilder; + var applicationStoppingToken = GetStoppingToken( applicationBuilder ); + + // Since we might want to proxy WebSockets requests (e.g., by default, AngularCliMiddleware + // requires it), enable it for the app + applicationBuilder.UseWebSockets(); + + // It's important not to time out the requests, as some of them might be to + // server-sent event endpoints or similar, where it's expected that the response + // takes an unlimited time and never actually completes + var neverTimeOutHttpClient = + SpaProxy.CreateHttpClientForProxy( Timeout.InfiniteTimeSpan ); + + applicationBuilder.Use(async(context, next) => { + if ( context.Request.Path.Value != null && context.Request.Path.Value.StartsWith( "/view/" ) ) { + await next(); + return; + } + await SpaProxy.PerformProxyRequest( + context, neverTimeOutHttpClient, baseUriTaskFactory(), applicationStoppingToken, + proxy404s: true ); + } ); + } + + private static CancellationToken GetStoppingToken( IApplicationBuilder appBuilder ) { + var applicationLifetime = appBuilder + .ApplicationServices + .GetRequiredService(); + return applicationLifetime.ApplicationStopping; + } +} diff --git a/src/Util.Ui/Sources/Spa/SpaApplicationBuilderExtensions.cs b/src/Util.Ui/Sources/Spa/SpaApplicationBuilderExtensions.cs new file mode 100644 index 000000000..554d2f35f --- /dev/null +++ b/src/Util.Ui/Sources/Spa/SpaApplicationBuilderExtensions.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Util.Ui.Sources.Spa; + +/// +/// Provides extension methods used for configuring an application to +/// host a client-side Single Page Application (SPA). +/// +public static class SpaApplicationBuilderExtensions { + /// + /// Handles all requests from this point in the middleware chain by returning + /// the default page for the Single Page Application (SPA). + /// + /// This middleware should be placed late in the chain, so that other middleware + /// for serving static files, MVC actions, etc., takes precedence. + /// + /// The . + /// + /// This callback will be invoked so that additional middleware can be registered within + /// the context of this SPA. + /// + public static void UseAngular( this IApplicationBuilder app, Action configuration ) { + ArgumentNullException.ThrowIfNull( configuration ); + var optionsProvider = app.ApplicationServices.GetService>()!; + var options = new SpaOptions( optionsProvider.Value ); + var spaBuilder = new DefaultSpaBuilder( app, options ); + configuration.Invoke( spaBuilder ); + SpaDefaultPageMiddleware.Attach( spaBuilder ); + } +} diff --git a/src/Util.Ui/Sources/Spa/SpaDefaultPageMiddleware.cs b/src/Util.Ui/Sources/Spa/SpaDefaultPageMiddleware.cs new file mode 100644 index 000000000..be3d1de23 --- /dev/null +++ b/src/Util.Ui/Sources/Spa/SpaDefaultPageMiddleware.cs @@ -0,0 +1,51 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Hosting; +using Util.Ui.Sources.Spa.StaticFiles; + +namespace Util.Ui.Sources.Spa; + +internal sealed class SpaDefaultPageMiddleware +{ + public static void Attach(ISpaBuilder spaBuilder) + { + ArgumentNullException.ThrowIfNull(spaBuilder); + + var app = spaBuilder.ApplicationBuilder; + var options = spaBuilder.Options; + + // Rewrite all requests to the default page + app.Use((context, next) => + { + // If we have an Endpoint, then this is a deferred match - just noop. + if (context.GetEndpoint() != null) + { + return next(context); + } + + context.Request.Path = options.DefaultPage; + return next(context); + }); + + // Serve it as a static file + // Developers who need to host more than one SPA with distinct default pages can + // override the file provider + app.UseSpaStaticFilesInternal( + options.DefaultPageStaticFileOptions ?? new StaticFileOptions(), + allowFallbackOnServingWebRootFiles: true); + + // If the default file didn't get served as a static file (usually because it was not + // present on disk), the SPA is definitely not going to work. + //app.Use( async (context, next) => + //{ + // // If we have an Endpoint, then this is a deferred match - just noop. + // if (context.GetEndpoint() != null) + // { + // await next(context); + // } + // await Task.Delay(100); + // await next( context ); + //} ); + } +} diff --git a/src/Util.Ui/Sources/Spa/SpaOptions.cs b/src/Util.Ui/Sources/Spa/SpaOptions.cs new file mode 100644 index 000000000..17ca754b0 --- /dev/null +++ b/src/Util.Ui/Sources/Spa/SpaOptions.cs @@ -0,0 +1,99 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Util.Ui.Sources.Spa; + +/// +/// Describes options for hosting a Single Page Application (SPA). +/// +public class SpaOptions +{ + private PathString _defaultPage = "/index.html"; + private string _packageManagerCommand = "npm"; + + /// + /// Constructs a new instance of . + /// + public SpaOptions() + { + } + + /// + /// Constructs a new instance of . + /// + /// An instance of from which values should be copied. + internal SpaOptions(SpaOptions copyFromOptions) + { + _defaultPage = copyFromOptions.DefaultPage; + _packageManagerCommand = copyFromOptions.PackageManagerCommand; + DefaultPageStaticFileOptions = copyFromOptions.DefaultPageStaticFileOptions; + SourcePath = copyFromOptions.SourcePath; + DevServerPort = copyFromOptions.DevServerPort; + } + + /// + /// Gets or sets the URL of the default page that hosts your SPA user interface. + /// The default value is "/index.html". + /// + public PathString DefaultPage + { + get => _defaultPage; + set + { + if (string.IsNullOrEmpty(value.Value)) + { + throw new ArgumentException($"The value for {nameof(DefaultPage)} cannot be null or empty."); + } + + _defaultPage = value; + } + } + + /// + /// Gets or sets the that supplies content + /// for serving the SPA's default page. + /// + /// If not set, a default file provider will read files from the + /// , which by default is + /// the wwwroot directory. + /// + public StaticFileOptions DefaultPageStaticFileOptions { get; set; } + + /// + /// Gets or sets the path, relative to the application working directory, + /// of the directory that contains the SPA source files during + /// development. The directory may not exist in published applications. + /// + public string SourcePath { get; set; } + + /// + /// Controls whether the development server should be used with a dynamic or fixed port. + /// + public int DevServerPort { get; set; } + + /// + /// Gets or sets the name of the package manager executable, (e.g npm, + /// yarn) to run the SPA. + /// + /// The default value is 'npm'. + /// + public string PackageManagerCommand + { + get => _packageManagerCommand; + set + { + if (string.IsNullOrEmpty(value)) + { + throw new ArgumentException($"The value for {nameof(PackageManagerCommand)} cannot be null or empty."); + } + + _packageManagerCommand = value; + } + } + + /// + /// Gets or sets the maximum duration that a request will wait for the SPA + /// to become ready to serve to the client. + /// + public TimeSpan StartupTimeout { get; set; } = TimeSpan.FromSeconds(120); +} diff --git a/src/Util.Ui/Sources/Spa/StaticFiles/DefaultSpaStaticFileProvider.cs b/src/Util.Ui/Sources/Spa/StaticFiles/DefaultSpaStaticFileProvider.cs new file mode 100644 index 000000000..278bf54e2 --- /dev/null +++ b/src/Util.Ui/Sources/Spa/StaticFiles/DefaultSpaStaticFileProvider.cs @@ -0,0 +1,44 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.FileProviders; + +namespace Util.Ui.Sources.Spa.StaticFiles; + +/// +/// Provides an implementation of that supplies +/// physical files at a location configured using . +/// +internal sealed class DefaultSpaStaticFileProvider : ISpaStaticFileProvider +{ + private readonly IFileProvider _fileProvider; + + public DefaultSpaStaticFileProvider( + IServiceProvider serviceProvider, + SpaStaticFilesOptions options) + { + ArgumentNullException.ThrowIfNull(options); + + if (string.IsNullOrEmpty(options.RootPath)) + { + throw new ArgumentException($"The {nameof(options.RootPath)} property " + + $"of {nameof(options)} cannot be null or empty."); + } + + var env = serviceProvider.GetRequiredService(); + var absoluteRootPath = Path.Combine( + env.ContentRootPath, + options.RootPath); + + // PhysicalFileProvider will throw if you pass a non-existent path, + // but we don't want that scenario to be an error because for SPA + // scenarios, it's better if non-existing directory just means we + // don't serve any static files. + if (Directory.Exists(absoluteRootPath)) + { + _fileProvider = new PhysicalFileProvider(absoluteRootPath); + } + } + + public IFileProvider FileProvider => _fileProvider; +} diff --git a/src/Util.Ui/Sources/Spa/StaticFiles/ISpaStaticFileProvider.cs b/src/Util.Ui/Sources/Spa/StaticFiles/ISpaStaticFileProvider.cs new file mode 100644 index 000000000..bd5bb72c4 --- /dev/null +++ b/src/Util.Ui/Sources/Spa/StaticFiles/ISpaStaticFileProvider.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.FileProviders; + +namespace Util.Ui.Sources.Spa.StaticFiles; + +/// +/// Represents a service that can provide static files to be served for a Single Page +/// Application (SPA). +/// +public interface ISpaStaticFileProvider +{ + /// + /// Gets the file provider, if available, that supplies the static files for the SPA. + /// The value is null if no file provider is available. + /// + IFileProvider FileProvider { get; } +} diff --git a/src/Util.Ui/Sources/Spa/StaticFiles/SpaStaticFilesExtensions.cs b/src/Util.Ui/Sources/Spa/StaticFiles/SpaStaticFilesExtensions.cs new file mode 100644 index 000000000..3037390ad --- /dev/null +++ b/src/Util.Ui/Sources/Spa/StaticFiles/SpaStaticFilesExtensions.cs @@ -0,0 +1,131 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.FileProviders; + +namespace Util.Ui.Sources.Spa.StaticFiles; + +/// +/// Extension methods for configuring an application to serve static files for a +/// Single Page Application (SPA). +/// +public static class SpaStaticFilesExtensions +{ + /// + /// Registers an service that can provide static + /// files to be served for a Single Page Application (SPA). + /// + /// The . + /// If specified, this callback will be invoked to set additional configuration options. + public static void AddSpaStaticFiles( + this IServiceCollection services, + Action configuration = null) + { + services.AddSingleton(serviceProvider => + { + // Use the options configured in DI (or blank if none was configured) + var optionsProvider = serviceProvider.GetService>()!; + var options = optionsProvider.Value; + + // Allow the developer to perform further configuration + configuration?.Invoke(options); + + if (string.IsNullOrEmpty(options.RootPath)) + { + throw new InvalidOperationException($"No {nameof(SpaStaticFilesOptions.RootPath)} " + + $"was set on the {nameof(SpaStaticFilesOptions)}."); + } + + return new DefaultSpaStaticFileProvider(serviceProvider, options); + }); + } + + /// + /// Configures the application to serve static files for a Single Page Application (SPA). + /// The files will be located using the registered service. + /// + /// The . + public static void UseSpaStaticFiles(this IApplicationBuilder applicationBuilder) + { + UseSpaStaticFiles(applicationBuilder, new StaticFileOptions()); + } + + /// + /// Configures the application to serve static files for a Single Page Application (SPA). + /// The files will be located using the registered service. + /// + /// The . + /// Specifies options for serving the static files. + public static void UseSpaStaticFiles(this IApplicationBuilder applicationBuilder, StaticFileOptions options) + { + ArgumentNullException.ThrowIfNull(applicationBuilder); + ArgumentNullException.ThrowIfNull(options); + + UseSpaStaticFilesInternal(applicationBuilder, + staticFileOptions: options, + allowFallbackOnServingWebRootFiles: false); + } + + internal static void UseSpaStaticFilesInternal( + this IApplicationBuilder app, + StaticFileOptions staticFileOptions, + bool allowFallbackOnServingWebRootFiles) + { + ArgumentNullException.ThrowIfNull(staticFileOptions); + + // If the file provider was explicitly supplied, that takes precedence over any other + // configured file provider. This is most useful if the application hosts multiple SPAs + // (via multiple calls to UseSpa()), so each needs to serve its own separate static files + // instead of using AddSpaStaticFiles/UseSpaStaticFiles. + // But if no file provider was specified, try to get one from the DI config. + if (staticFileOptions.FileProvider == null) + { + var shouldServeStaticFiles = ShouldServeStaticFiles( + app, + allowFallbackOnServingWebRootFiles, + out var fileProviderOrDefault); + if (shouldServeStaticFiles) + { + staticFileOptions.FileProvider = fileProviderOrDefault; + } + else + { + // The registered ISpaStaticFileProvider says we shouldn't + // serve static files + return; + } + } + + app.UseStaticFiles(staticFileOptions); + } + + private static bool ShouldServeStaticFiles( + IApplicationBuilder app, + bool allowFallbackOnServingWebRootFiles, + out IFileProvider fileProviderOrDefault) + { + var spaStaticFilesService = app.ApplicationServices.GetService(); + if (spaStaticFilesService != null) + { + // If an ISpaStaticFileProvider was configured but it says no IFileProvider is available + // (i.e., it supplies 'null'), this implies we should not serve any static files. This + // is typically the case in development when SPA static files are being served from a + // SPA development server (e.g., Angular CLI or create-react-app), in which case no + // directory of prebuilt files will exist on disk. + fileProviderOrDefault = spaStaticFilesService.FileProvider; + return fileProviderOrDefault != null; + } + else if (!allowFallbackOnServingWebRootFiles) + { + throw new InvalidOperationException($"To use {nameof(UseSpaStaticFiles)}, you must " + + $"first register an {nameof(ISpaStaticFileProvider)} in the service provider, typically " + + $"by calling services.{nameof(AddSpaStaticFiles)}."); + } + else + { + // Fall back on serving wwwroot + fileProviderOrDefault = null; + return true; + } + } +} diff --git a/src/Util.Ui/Sources/Spa/StaticFiles/SpaStaticFilesOptions.cs b/src/Util.Ui/Sources/Spa/StaticFiles/SpaStaticFilesOptions.cs new file mode 100644 index 000000000..3f2152f81 --- /dev/null +++ b/src/Util.Ui/Sources/Spa/StaticFiles/SpaStaticFilesOptions.cs @@ -0,0 +1,20 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Util.Ui.Sources.Spa.StaticFiles; + +/// +/// Represents options for serving static files for a Single Page Application (SPA). +/// +public class SpaStaticFilesOptions +{ + /// + /// Gets or sets the path, relative to the application root, of the directory in which + /// the physical files are located. + /// + /// If the specified directory does not exist, then the + /// + /// middleware will not serve any static files. + /// + public string RootPath { get; set; } = default!; +} diff --git a/src/Util.Ui/Sources/Spa/Util/EventedStreamReader.cs b/src/Util.Ui/Sources/Spa/Util/EventedStreamReader.cs new file mode 100644 index 000000000..2a7cabbbb --- /dev/null +++ b/src/Util.Ui/Sources/Spa/Util/EventedStreamReader.cs @@ -0,0 +1,130 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.RegularExpressions; + +namespace Util.Ui.Sources.Spa.Util; + +/// +/// Wraps a to expose an evented API, issuing notifications +/// when the stream emits partial lines, completed lines, or finally closes. +/// +internal sealed class EventedStreamReader +{ + public delegate void OnReceivedChunkHandler(ArraySegment chunk); + public delegate void OnReceivedLineHandler(string line); + public delegate void OnStreamClosedHandler(); + + public event OnReceivedChunkHandler OnReceivedChunk; + public event OnReceivedLineHandler OnReceivedLine; + public event OnStreamClosedHandler OnStreamClosed; + + private readonly StreamReader _streamReader; + private readonly StringBuilder _linesBuffer; + + public EventedStreamReader(StreamReader streamReader) + { + _streamReader = streamReader ?? throw new ArgumentNullException(nameof(streamReader)); + _linesBuffer = new StringBuilder(); + Task.Factory.StartNew(Run, CancellationToken.None, TaskCreationOptions.DenyChildAttach, TaskScheduler.Default); + } + + public Task WaitForMatch(string[] contents) + { + var tcs = new TaskCompletionSource(); + var completionLock = new object(); + + OnReceivedLineHandler onReceivedLineHandler = null; + OnStreamClosedHandler onStreamClosedHandler = null; + + void ResolveIfStillPending(Action applyResolution) + { + lock (completionLock) + { + if (!tcs.Task.IsCompleted) + { + OnReceivedLine -= onReceivedLineHandler; + OnStreamClosed -= onStreamClosedHandler; + applyResolution(); + } + } + } + + onReceivedLineHandler = line => { + + if ( contents.Any( content => line.Contains( content,StringComparison.OrdinalIgnoreCase ) ) ) { + Console.WriteLine( $"dbug: {line}" ); + ResolveIfStillPending( () => tcs.SetResult( true ) ); + } + }; + + onStreamClosedHandler = () => + { + ResolveIfStillPending(() => tcs.SetException(new EndOfStreamException())); + }; + + OnReceivedLine += onReceivedLineHandler; + OnStreamClosed += onStreamClosedHandler; + + return tcs.Task; + } + + private async Task Run() + { + var buf = new char[8 * 1024]; + while (true) + { + var chunkLength = await _streamReader.ReadAsync(buf, 0, buf.Length); + if (chunkLength == 0) + { + if (_linesBuffer.Length > 0) + { + OnCompleteLine(_linesBuffer.ToString()); + _linesBuffer.Clear(); + } + + OnClosed(); + break; + } + + OnChunk(new ArraySegment(buf, 0, chunkLength)); + + int lineBreakPos; + var startPos = 0; + + // get all the newlines + while ((lineBreakPos = Array.IndexOf(buf, '\n', startPos, chunkLength - startPos)) >= 0 && startPos < chunkLength) + { + var length = (lineBreakPos + 1) - startPos; + _linesBuffer.Append(buf, startPos, length); + OnCompleteLine(_linesBuffer.ToString()); + _linesBuffer.Clear(); + startPos = lineBreakPos + 1; + } + + // get the rest + if (lineBreakPos < 0 && startPos < chunkLength) + { + _linesBuffer.Append(buf, startPos, chunkLength - startPos); + } + } + } + + private void OnChunk(ArraySegment chunk) + { + var dlg = OnReceivedChunk; + dlg?.Invoke(chunk); + } + + private void OnCompleteLine(string line) + { + var dlg = OnReceivedLine; + dlg?.Invoke(line); + } + + private void OnClosed() + { + var dlg = OnStreamClosed; + dlg?.Invoke(); + } +} diff --git a/src/Util.Ui/Sources/Spa/Util/EventedStreamStringReader.cs b/src/Util.Ui/Sources/Spa/Util/EventedStreamStringReader.cs new file mode 100644 index 000000000..ad5b7c5c2 --- /dev/null +++ b/src/Util.Ui/Sources/Spa/Util/EventedStreamStringReader.cs @@ -0,0 +1,35 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Util.Ui.Sources.Spa.Util; + +/// +/// Captures the completed-line notifications from a , +/// combining the data into a single . +/// +internal sealed class EventedStreamStringReader : IDisposable +{ + private readonly EventedStreamReader _eventedStreamReader; + private bool _isDisposed; + private readonly StringBuilder _stringBuilder = new StringBuilder(); + + public EventedStreamStringReader(EventedStreamReader eventedStreamReader) + { + _eventedStreamReader = eventedStreamReader + ?? throw new ArgumentNullException(nameof(eventedStreamReader)); + _eventedStreamReader.OnReceivedLine += OnReceivedLine; + } + + public string ReadAsString() => _stringBuilder.ToString(); + + private void OnReceivedLine(string line) => _stringBuilder.AppendLine(line); + + public void Dispose() + { + if (!_isDisposed) + { + _eventedStreamReader.OnReceivedLine -= OnReceivedLine; + _isDisposed = true; + } + } +} diff --git a/src/Util.Ui/Sources/Spa/Util/LoggerFinder.cs b/src/Util.Ui/Sources/Spa/Util/LoggerFinder.cs new file mode 100644 index 000000000..10009b39a --- /dev/null +++ b/src/Util.Ui/Sources/Spa/Util/LoggerFinder.cs @@ -0,0 +1,22 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Util.Ui.Sources.Spa.Util; + +internal static class LoggerFinder +{ + public static ILogger GetOrCreateLogger( + IApplicationBuilder appBuilder, + string logCategoryName) + { + // If the DI system gives us a logger, use it. Otherwise, set up a default one + var loggerFactory = appBuilder.ApplicationServices.GetService(); + var logger = loggerFactory != null + ? loggerFactory.CreateLogger(logCategoryName) + : NullLogger.Instance; + return logger; + } +} diff --git a/src/Util.Ui/Sources/Spa/Util/TaskTimeoutExtensions.cs b/src/Util.Ui/Sources/Spa/Util/TaskTimeoutExtensions.cs new file mode 100644 index 000000000..5de1b2585 --- /dev/null +++ b/src/Util.Ui/Sources/Spa/Util/TaskTimeoutExtensions.cs @@ -0,0 +1,31 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Util.Ui.Sources.Spa.Util; + +internal static class TaskTimeoutExtensions +{ + public static async Task WithTimeout(this Task task, TimeSpan timeoutDelay, string message) + { + if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay))) + { + task.Wait(); // Allow any errors to propagate + } + else + { + throw new TimeoutException(message); + } + } + + public static async Task WithTimeout(this Task task, TimeSpan timeoutDelay, string message) + { + if (task == await Task.WhenAny(task, Task.Delay(timeoutDelay))) + { + return task.Result; + } + else + { + throw new TimeoutException(message); + } + } +} diff --git a/src/Util.Ui/Sources/Spa/Util/TcpPortFinder.cs b/src/Util.Ui/Sources/Spa/Util/TcpPortFinder.cs new file mode 100644 index 000000000..ab9fc4c0a --- /dev/null +++ b/src/Util.Ui/Sources/Spa/Util/TcpPortFinder.cs @@ -0,0 +1,21 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Util.Ui.Sources.Spa.Util; + +internal static class TcpPortFinder +{ + public static int FindAvailablePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + try + { + return ((IPEndPoint)listener.LocalEndpoint).Port; + } + finally + { + listener.Stop(); + } + } +} diff --git a/src/Util.Ui/Usings.cs b/src/Util.Ui/Usings.cs index 44c2a71ce..297c58d34 100644 --- a/src/Util.Ui/Usings.cs +++ b/src/Util.Ui/Usings.cs @@ -10,6 +10,14 @@ global using System.ComponentModel.DataAnnotations; global using System.Text.Encodings.Web; global using System.Threading; +global using System.Text; +global using System.Globalization; +global using System.Net.Sockets; +global using System.Net; +global using System.Net.Http; +global using Microsoft.AspNetCore.Hosting; +global using Microsoft.AspNetCore.Http; +global using Microsoft.AspNetCore.Builder; global using Microsoft.Extensions.DependencyInjection; global using Microsoft.AspNetCore.Html; global using Microsoft.AspNetCore.Razor.TagHelpers; diff --git a/test/Util.Core.Tests/Helpers/UrlTest.cs b/test/Util.Core.Tests/Helpers/UrlTest.cs index a46d4dd0f..7aed059f1 100644 --- a/test/Util.Core.Tests/Helpers/UrlTest.cs +++ b/test/Util.Core.Tests/Helpers/UrlTest.cs @@ -49,4 +49,36 @@ public void TestJoinPath_3() { public void TestJoinPath_4() { Assert.Equal( "a/c", Url.JoinPath( "a/b", "../c" ) ); } + + /// + /// 测试连接路径 - http打头 + /// + [Fact] + public void TestJoinPath_5() { + Assert.Equal( "http://a.com/a/c", Url.JoinPath( "http://a.com", "a/b", "../c" ) ); + } + + /// + /// 测试连接路径 - https打头 + /// + [Fact] + public void TestJoinPath_6() { + Assert.Equal( "https://a.com/a/c", Url.JoinPath( "https://a.com/", "a/b/", "../c" ) ); + } + + /// + /// 测试连接路径 - https打头,带..路径 + /// + [Fact] + public void TestJoinPath_7() { + Assert.Equal( "https://a.com/a/c/D/F/g", Url.JoinPath( "Https://a.com/a/b/", "../c/D/e/", "../F/g" ) ); + } + + /// + /// 测试连接路径 - https打头,带..路径 + /// + [Fact] + public void TestJoinPath_8() { + Assert.Equal( "https://a.com/view/a", Url.JoinPath( "https://a.com", "view", "a" ) ); + } } \ No newline at end of file diff --git a/test/Util.Ui.NgZorro.Tests/Descriptions/DescriptionItemTagHelperTest.Expression.cs b/test/Util.Ui.NgZorro.Tests/Descriptions/DescriptionItemTagHelperTest.Expression.cs index 8b70a306d..fcd9daca4 100644 --- a/test/Util.Ui.NgZorro.Tests/Descriptions/DescriptionItemTagHelperTest.Expression.cs +++ b/test/Util.Ui.NgZorro.Tests/Descriptions/DescriptionItemTagHelperTest.Expression.cs @@ -104,5 +104,19 @@ public void TestFor_4() { result.Append( "{{model.birthday|date:\"yyyy-MM\"}}" ); Assert.Equal( result.ToString(), GetResult() ); } + + /// + /// 测试属性表达式 - 数值类型 + /// + [Fact] + public void TestFor_5() { + NgZorroOptionsService.SetOptions( new NgZorroOptions { EnableI18n = true } ); + _wrapper.SetExpression( t => t.IdCard ); + var result = new StringBuilder(); + result.Append( "" ); + result.Append( "{{model.idCard}}" ); + result.Append( "" ); + Assert.Equal( result.ToString(), GetResult() ); + } } } \ No newline at end of file diff --git a/test/Util.Ui.NgZorro.Tests/Descriptions/DescriptionItemTagHelperTest.I18n.cs b/test/Util.Ui.NgZorro.Tests/Descriptions/DescriptionItemTagHelperTest.I18n.cs index 5c069795b..cd0edfba5 100644 --- a/test/Util.Ui.NgZorro.Tests/Descriptions/DescriptionItemTagHelperTest.I18n.cs +++ b/test/Util.Ui.NgZorro.Tests/Descriptions/DescriptionItemTagHelperTest.I18n.cs @@ -4,43 +4,43 @@ using Util.Ui.NgZorro.Configs; using Xunit; -namespace Util.Ui.NgZorro.Tests.Descriptions { +namespace Util.Ui.NgZorro.Tests.Descriptions; + +/// +/// 描述列表项测试 - 多语言支持 +/// +public partial class DescriptionItemTagHelperTest { /// - /// 描述列表项测试 - 多语言支持 + /// 测试标题 /// - public partial class DescriptionItemTagHelperTest { - /// - /// 测试标题 - /// - [Fact] - public void TestTitle() { - _wrapper.SetContextAttribute( UiConst.Title, "a" ); - var result = new StringBuilder(); - result.Append( "" ); - Assert.Equal( result.ToString(), GetResult() ); - } + [Fact] + public void TestTitle() { + _wrapper.SetContextAttribute( UiConst.Title, "a" ); + var result = new StringBuilder(); + result.Append( "" ); + Assert.Equal( result.ToString(), GetResult() ); + } - /// - /// 测试标题- 多语言 - /// - [Fact] - public void TestTitle_I18n() { - NgZorroOptionsService.SetOptions( new NgZorroOptions { EnableI18n = true } ); - _wrapper.SetContextAttribute( UiConst.Title, "a" ); - var result = new StringBuilder(); - result.Append( "" ); - Assert.Equal( result.ToString(), GetResult() ); - } + /// + /// 测试标题- 多语言 + /// + [Fact] + public void TestTitle_I18n() { + NgZorroOptionsService.SetOptions( new NgZorroOptions { EnableI18n = true } ); + _wrapper.SetContextAttribute( UiConst.Title, "a" ); + var result = new StringBuilder(); + result.Append( "" ); + Assert.Equal( result.ToString(), GetResult() ); + } - /// - /// 测试标题 - /// - [Fact] - public void TestBindTitle() { - _wrapper.SetContextAttribute( AngularConst.BindTitle, "a" ); - var result = new StringBuilder(); - result.Append( "" ); - Assert.Equal( result.ToString(), GetResult() ); - } + /// + /// 测试标题 + /// + [Fact] + public void TestBindTitle() { + _wrapper.SetContextAttribute( AngularConst.BindTitle, "a" ); + var result = new StringBuilder(); + result.Append( "" ); + Assert.Equal( result.ToString(), GetResult() ); } } \ No newline at end of file diff --git a/test/Util.Ui.NgZorro.Tests/Startup.cs b/test/Util.Ui.NgZorro.Tests/Startup.cs index 8310cb623..3bb9ce9ac 100644 --- a/test/Util.Ui.NgZorro.Tests/Startup.cs +++ b/test/Util.Ui.NgZorro.Tests/Startup.cs @@ -12,13 +12,14 @@ public class Startup { /// public void ConfigureHost( IHostBuilder hostBuilder ) { hostBuilder.ConfigureDefaults( null ).AddUtil(); + Util.Helpers.Environment.IsTest = true; } - /// - /// ÷ - /// - public void ConfigureServices( IServiceCollection services ) { - services.AddLogging( logBuilder => logBuilder.AddXunitOutput() ); - } - } + /// + /// ÷ + /// + public void ConfigureServices( IServiceCollection services ) { + services.AddLogging( logBuilder => logBuilder.AddXunitOutput() ); + } + } } diff --git a/test/Util.Ui.NgZorro.Tests/Tables/TableColumnTagHelperTest.Extend.cs b/test/Util.Ui.NgZorro.Tests/Tables/TableColumnTagHelperTest.Extend.cs index 3514b9166..4d650187c 100644 --- a/test/Util.Ui.NgZorro.Tests/Tables/TableColumnTagHelperTest.Extend.cs +++ b/test/Util.Ui.NgZorro.Tests/Tables/TableColumnTagHelperTest.Extend.cs @@ -624,4 +624,21 @@ public void TestAcl() { } #endregion + + #region Tooltip + + /// + /// 测试提示文字 + /// + [Fact] + public void TestTooltipTitle() { + _wrapper.SetContextAttribute( UiConst.Column, "a" ); + _wrapper.AppendContent( "b" ); + _wrapper.SetContextAttribute( UiConst.TooltipTitle, "a" ); + var result = new StringBuilder(); + result.Append( "b" ); + Assert.Equal( result.ToString(), GetResult() ); + } + + #endregion } \ No newline at end of file diff --git a/test/Util.Ui.NgZorro.Tests/Tables/TableColumnTagHelperTest.cs b/test/Util.Ui.NgZorro.Tests/Tables/TableColumnTagHelperTest.cs index e068d87b4..e9322eec3 100644 --- a/test/Util.Ui.NgZorro.Tests/Tables/TableColumnTagHelperTest.cs +++ b/test/Util.Ui.NgZorro.Tests/Tables/TableColumnTagHelperTest.cs @@ -416,4 +416,15 @@ public void TestOnExpandChange() { result.Append( "" ); Assert.Equal( result.ToString(), GetResult() ); } + + /// + /// 测试单击事件 + /// + [Fact] + public void TestOnClick() { + _wrapper.SetContextAttribute( UiConst.OnClick, "a" ); + var result = new StringBuilder(); + result.Append( "" ); + Assert.Equal( result.ToString(), GetResult() ); + } } \ No newline at end of file diff --git a/test/Util.Ui.NgZorro.Tests/Tables/TableTagHelperTest.TableSettings.cs b/test/Util.Ui.NgZorro.Tests/Tables/TableTagHelperTest.TableSettings.cs index fdb3433a1..a5b015b03 100644 --- a/test/Util.Ui.NgZorro.Tests/Tables/TableTagHelperTest.TableSettings.cs +++ b/test/Util.Ui.NgZorro.Tests/Tables/TableTagHelperTest.TableSettings.cs @@ -15,7 +15,6 @@ public partial class TableTagHelperTest { /// [Fact] public void TestEnableTableSettings_1() { - //启用自定义列 _wrapper.SetContextAttribute( UiConst.Key, "k" ); _wrapper.SetContextAttribute( UiConst.EnableTableSettings, true ); @@ -56,7 +55,6 @@ public void TestEnableTableSettings_1() { /// [Fact] public void TestEnableTableSettings_2() { - //启用自定义列 _wrapper.SetContextAttribute( UiConst.Key, "k" ); _wrapper.SetContextAttribute( UiConst.EnableTableSettings, true ); _wrapper.SetContextAttribute( UiConst.Size, TableSize.Small ); @@ -98,7 +96,6 @@ public void TestEnableTableSettings_2() { /// [Fact] public void TestEnableTableSettings_3() { - //启用自定义列 _wrapper.SetContextAttribute( UiConst.Key, "k" ); _wrapper.SetContextAttribute( UiConst.EnableTableSettings, true ); _wrapper.SetContextAttribute( UiConst.ScrollHeight, "1" ); @@ -140,7 +137,6 @@ public void TestEnableTableSettings_3() { /// [Fact] public void TestEnableTableSettings_4() { - //启用自定义列 _wrapper.SetContextAttribute( UiConst.Key, "k" ); _wrapper.SetContextAttribute( UiConst.EnableTableSettings, true ); _wrapper.SetContextAttribute( UiConst.ScrollWidth, "1" ); @@ -182,7 +178,6 @@ public void TestEnableTableSettings_4() { /// [Fact] public void TestEnableTableSettings_5() { - //启用自定义列 _wrapper.SetContextAttribute( UiConst.Key, "k" ); _wrapper.SetContextAttribute( UiConst.EnableTableSettings, true ); _wrapper.SetContextAttribute( UiConst.Scroll, "{x:'1px',y:'1px'}" ); @@ -224,7 +219,6 @@ public void TestEnableTableSettings_5() { /// [Fact] public void TestEnableTableSettings_6() { - //启用自定义列 _wrapper.SetContextAttribute( UiConst.Key, "k" ); _wrapper.SetContextAttribute( UiConst.EnableTableSettings, true ); _wrapper.SetContextAttribute( UiConst.Bordered, true ); @@ -404,4 +398,41 @@ public void TestEnableTableSettings_8() { result.Append( "" ); Assert.Equal( result.ToString(), GetResult() ); } + + /// + /// 测试启用表格设置 - 编辑列显示区域嵌套列 + /// + [Fact] + public void TestEnableTableSettings_9() { + _wrapper.SetContextAttribute( UiConst.Id, "tb" ); + _wrapper.SetContextAttribute( UiConst.Key, "k" ); + _wrapper.SetContextAttribute( UiConst.EnableTableSettings, true ); + + //添加列 + var column = new TableColumnTagHelper().ToWrapper(); + column.SetContextAttribute( UiConst.IsEdit, true ); + column.SetContextAttribute( UiConst.Title, "a" ); + column.SetContextAttribute( UiConst.Column, "name" ); + _wrapper.AppendContent( column ); + + //添加显示区域 + var display = new TableColumnDisplayTagHelper().ToWrapper(); + var subColumn = new TableColumnTagHelper().ToWrapper(); + subColumn.SetContextAttribute( UiConst.Title, "b" ); + subColumn.SetContextAttribute( UiConst.Column, "c" ); + display.AppendContent( subColumn ); + column.AppendContent( display ); + + //添加控件区域 + var control = new TableColumnControlTagHelper().ToWrapper(); + control.AppendContent( "b" ); + column.AppendContent( control ); + + //结果 + var result = new StringBuilder(); + result.Append( "" ); + result.Append( "" ); + Assert.EndsWith( result.ToString(), GetResult() ); + } } \ No newline at end of file