| | 1 | | using Microsoft.AspNetCore.Server.Kestrel.Core; |
| | 2 | | using System.Net; |
| | 3 | | using System.Management.Automation; |
| | 4 | | using System.Management.Automation.Runspaces; |
| | 5 | | using Kestrun.Utilities; |
| | 6 | | using Microsoft.CodeAnalysis; |
| | 7 | | using System.Reflection; |
| | 8 | | using System.Security.Cryptography.X509Certificates; |
| | 9 | | using Serilog; |
| | 10 | | using Serilog.Events; |
| | 11 | | using Microsoft.AspNetCore.SignalR; |
| | 12 | | using Kestrun.Scheduling; |
| | 13 | | using Kestrun.SharedState; |
| | 14 | | using Kestrun.Middleware; |
| | 15 | | using Kestrun.Scripting; |
| | 16 | | using Kestrun.Hosting.Options; |
| | 17 | | using System.Runtime.InteropServices; |
| | 18 | | using Microsoft.PowerShell; |
| | 19 | |
|
| | 20 | | namespace Kestrun.Hosting; |
| | 21 | |
|
| | 22 | | /// <summary> |
| | 23 | | /// Provides hosting and configuration for the Kestrun application, including service registration, middleware setup, an |
| | 24 | | /// </summary> |
| | 25 | | public class KestrunHost : IDisposable |
| | 26 | | { |
| | 27 | | #region Fields |
| 312 | 28 | | internal WebApplicationBuilder Builder { get; } |
| | 29 | |
|
| | 30 | | private WebApplication? _app; |
| | 31 | |
|
| 55 | 32 | | internal WebApplication App => _app ?? throw new InvalidOperationException("WebApplication is not built yet. Call Bu |
| | 33 | |
|
| | 34 | | /// <summary> |
| | 35 | | /// Gets the application name for the Kestrun host. |
| | 36 | | /// </summary> |
| 2 | 37 | | public string ApplicationName => Options.ApplicationName ?? "KestrunApp"; |
| | 38 | |
|
| | 39 | | /// <summary> |
| | 40 | | /// Gets the configuration options for the Kestrun host. |
| | 41 | | /// </summary> |
| 487 | 42 | | public KestrunOptions Options { get; private set; } = new(); |
| 138 | 43 | | private readonly List<string> _modulePaths = []; |
| | 44 | |
|
| | 45 | | /// <summary> |
| | 46 | | /// Indicates whether the Kestrun host configuration has been applied. |
| | 47 | | /// </summary> |
| 72 | 48 | | public bool IsConfigured { get; private set; } |
| | 49 | |
|
| | 50 | | private KestrunRunspacePoolManager? _runspacePool; |
| | 51 | |
|
| 0 | 52 | | internal KestrunRunspacePoolManager RunspacePool => _runspacePool ?? throw new InvalidOperationException("Runspace p |
| | 53 | | /// <summary> |
| | 54 | | /// Gets the root directory path for the Kestrun application. |
| | 55 | | /// </summary> |
| 78 | 56 | | public string? KestrunRoot { get; private set; } |
| | 57 | |
|
| | 58 | | /// <summary> |
| | 59 | | /// Gets the Serilog logger instance used by the Kestrun host. |
| | 60 | | /// </summary> |
| 1682 | 61 | | public Serilog.ILogger HostLogger { get; private set; } |
| | 62 | |
|
| | 63 | | /// <summary> |
| | 64 | | /// Gets the scheduler service used for managing scheduled tasks in the Kestrun host. |
| | 65 | | /// </summary> |
| 25 | 66 | | public SchedulerService Scheduler { get; internal set; } = null!; // Initialized in ConfigureServices |
| | 67 | |
|
| | 68 | |
|
| | 69 | | /// <summary> |
| | 70 | | /// Gets the stack used for managing route groups in the Kestrun host. |
| | 71 | | /// </summary> |
| 138 | 72 | | public System.Collections.Stack RouteGroupStack { get; } = new(); |
| | 73 | |
|
| | 74 | | // ── ✦ QUEUE #1 : SERVICE REGISTRATION ✦ ───────────────────────────── |
| 138 | 75 | | private readonly List<Action<IServiceCollection>> _serviceQueue = []; |
| | 76 | |
|
| | 77 | | // ── ✦ QUEUE #2 : MIDDLEWARE STAGES ✦ ──────────────────────────────── |
| 138 | 78 | | private readonly List<Action<IApplicationBuilder>> _middlewareQueue = []; |
| | 79 | |
|
| 186 | 80 | | internal List<Action<KestrunHost>> FeatureQueue { get; } = []; |
| | 81 | |
|
| 138 | 82 | | internal readonly Dictionary<(string Pattern, string Method), MapRouteOptions> _registeredRoutes = |
| 138 | 83 | | new( |
| 138 | 84 | | new RouteKeyComparer()); |
| | 85 | |
|
| | 86 | |
|
| | 87 | | #endregion |
| | 88 | |
|
| | 89 | |
|
| | 90 | | // Accepts optional module paths (from PowerShell) |
| | 91 | | #region Constructor |
| | 92 | |
|
| | 93 | | /// <summary> |
| | 94 | | /// Initializes a new instance of the <see cref="KestrunHost"/> class with the specified application name, root dire |
| | 95 | | /// </summary> |
| | 96 | | /// <param name="appName">The name of the application.</param> |
| | 97 | | /// <param name="kestrunRoot">The root directory for the Kestrun application.</param> |
| | 98 | | /// <param name="modulePathsObj">An array of module paths to be loaded.</param> |
| | 99 | | public KestrunHost(string? appName, string? kestrunRoot = null, string[]? modulePathsObj = null) : |
| 66 | 100 | | this(appName, Log.Logger, kestrunRoot, modulePathsObj) |
| 66 | 101 | | { } |
| | 102 | |
|
| | 103 | | /// <summary> |
| | 104 | | /// Initializes a new instance of the <see cref="KestrunHost"/> class with the specified application name, logger, r |
| | 105 | | /// </summary> |
| | 106 | | /// <param name="appName">The name of the application.</param> |
| | 107 | | /// <param name="logger">The Serilog logger instance to use.</param> |
| | 108 | | /// <param name="kestrunRoot">The root directory for the Kestrun application.</param> |
| | 109 | | /// <param name="modulePathsObj">An array of module paths to be loaded.</param> |
| 138 | 110 | | public KestrunHost(string? appName, Serilog.ILogger logger, string? kestrunRoot = null, string[]? modulePathsObj = n |
| | 111 | | { |
| | 112 | | // ① Logger |
| 138 | 113 | | HostLogger = logger ?? Log.Logger; |
| 138 | 114 | | LogConstructorArgs(appName, logger == null, kestrunRoot, modulePathsObj?.Length ?? 0); |
| | 115 | |
|
| | 116 | | // ② Working directory/root |
| 138 | 117 | | SetWorkingDirectoryIfNeeded(kestrunRoot); |
| | 118 | |
|
| | 119 | | // ③ Ensure Kestrun module path is available |
| 138 | 120 | | AddKestrunModulePathIfMissing(modulePathsObj); |
| | 121 | |
|
| | 122 | | // ④ Builder + logging |
| 138 | 123 | | Builder = WebApplication.CreateBuilder(); |
| 138 | 124 | | _ = Builder.Host.UseSerilog(); |
| | 125 | |
|
| | 126 | | // ⑤ Options |
| 138 | 127 | | InitializeOptions(appName); |
| | 128 | |
|
| | 129 | | // ⑥ Add user-provided module paths |
| 138 | 130 | | AddUserModulePaths(modulePathsObj); |
| | 131 | |
|
| 138 | 132 | | HostLogger.Information("Current working directory: {CurrentDirectory}", Directory.GetCurrentDirectory()); |
| 138 | 133 | | } |
| | 134 | | #endregion |
| | 135 | |
|
| | 136 | | #region Helpers |
| | 137 | |
|
| | 138 | |
|
| | 139 | | /// <summary> |
| | 140 | | /// Logs constructor arguments at Debug level for diagnostics. |
| | 141 | | /// </summary> |
| | 142 | | private void LogConstructorArgs(string? appName, bool defaultLogger, string? kestrunRoot, int modulePathsLength) |
| | 143 | | { |
| 138 | 144 | | if (HostLogger.IsEnabled(LogEventLevel.Debug)) |
| | 145 | | { |
| 88 | 146 | | HostLogger.Debug( |
| 88 | 147 | | "KestrunHost ctor: AppName={AppName}, DefaultLogger={DefaultLogger}, KestrunRoot={KestrunRoot}, ModulePa |
| 88 | 148 | | appName, defaultLogger, kestrunRoot, modulePathsLength); |
| | 149 | | } |
| 138 | 150 | | } |
| | 151 | |
|
| | 152 | | /// <summary> |
| | 153 | | /// Sets the current working directory to the provided Kestrun root if needed and stores it. |
| | 154 | | /// </summary> |
| | 155 | | /// <param name="kestrunRoot">The Kestrun root directory path.</param> |
| | 156 | | private void SetWorkingDirectoryIfNeeded(string? kestrunRoot) |
| | 157 | | { |
| 138 | 158 | | if (string.IsNullOrWhiteSpace(kestrunRoot)) |
| | 159 | | { |
| 61 | 160 | | return; |
| | 161 | | } |
| | 162 | |
|
| 77 | 163 | | if (!string.Equals(Directory.GetCurrentDirectory(), kestrunRoot, StringComparison.Ordinal)) |
| | 164 | | { |
| 55 | 165 | | Directory.SetCurrentDirectory(kestrunRoot); |
| 55 | 166 | | HostLogger.Information("Changed current directory to Kestrun root: {KestrunRoot}", kestrunRoot); |
| | 167 | | } |
| | 168 | | else |
| | 169 | | { |
| 22 | 170 | | HostLogger.Verbose("Current directory is already set to Kestrun root: {KestrunRoot}", kestrunRoot); |
| | 171 | | } |
| | 172 | |
|
| 77 | 173 | | KestrunRoot = kestrunRoot; |
| 77 | 174 | | } |
| | 175 | |
|
| | 176 | | /// <summary> |
| | 177 | | /// Ensures the core Kestrun module path is present; if missing, locates and adds it. |
| | 178 | | /// </summary> |
| | 179 | | /// <param name="modulePathsObj">The array of module paths to check.</param> |
| | 180 | | private void AddKestrunModulePathIfMissing(string[]? modulePathsObj) |
| | 181 | | { |
| 138 | 182 | | var needsLocate = modulePathsObj is null || |
| 160 | 183 | | (modulePathsObj?.Any(p => p.Contains("Kestrun.psm1", StringComparison.Ordinal)) == false); |
| 138 | 184 | | if (!needsLocate) |
| | 185 | | { |
| 22 | 186 | | return; |
| | 187 | | } |
| | 188 | |
|
| 116 | 189 | | var kestrunModulePath = PowerShellModuleLocator.LocateKestrunModule(); |
| 116 | 190 | | if (string.IsNullOrWhiteSpace(kestrunModulePath)) |
| | 191 | | { |
| 0 | 192 | | HostLogger.Fatal("Kestrun module not found. Ensure the Kestrun module is installed."); |
| 0 | 193 | | throw new FileNotFoundException("Kestrun module not found."); |
| | 194 | | } |
| | 195 | |
|
| 116 | 196 | | HostLogger.Information("Found Kestrun module at: {KestrunModulePath}", kestrunModulePath); |
| 116 | 197 | | HostLogger.Verbose("Adding Kestrun module path: {KestrunModulePath}", kestrunModulePath); |
| 116 | 198 | | _modulePaths.Add(kestrunModulePath); |
| 116 | 199 | | } |
| | 200 | |
|
| | 201 | | /// <summary> |
| | 202 | | /// Initializes Kestrun options and sets the application name when provided. |
| | 203 | | /// </summary> |
| | 204 | | /// <param name="appName">The name of the application.</param> |
| | 205 | | private void InitializeOptions(string? appName) |
| | 206 | | { |
| 138 | 207 | | if (string.IsNullOrEmpty(appName)) |
| | 208 | | { |
| 1 | 209 | | HostLogger.Information("No application name provided, using default."); |
| 1 | 210 | | Options = new KestrunOptions(); |
| | 211 | | } |
| | 212 | | else |
| | 213 | | { |
| 137 | 214 | | HostLogger.Information("Setting application name: {AppName}", appName); |
| 137 | 215 | | Options = new KestrunOptions { ApplicationName = appName }; |
| | 216 | | } |
| 137 | 217 | | } |
| | 218 | |
|
| | 219 | | /// <summary> |
| | 220 | | /// Adds user-provided module paths if they exist, logging warnings for invalid entries. |
| | 221 | | /// </summary> |
| | 222 | | /// <param name="modulePathsObj">The array of module paths to check.</param> |
| | 223 | | private void AddUserModulePaths(string[]? modulePathsObj) |
| | 224 | | { |
| 138 | 225 | | if (modulePathsObj is IEnumerable<object> modulePathsEnum) |
| | 226 | | { |
| 88 | 227 | | foreach (var modPathObj in modulePathsEnum) |
| | 228 | | { |
| 22 | 229 | | if (modPathObj is string modPath && !string.IsNullOrWhiteSpace(modPath)) |
| | 230 | | { |
| 22 | 231 | | if (File.Exists(modPath)) |
| | 232 | | { |
| 22 | 233 | | HostLogger.Information("[KestrunHost] Adding module path: {ModPath}", modPath); |
| 22 | 234 | | _modulePaths.Add(modPath); |
| | 235 | | } |
| | 236 | | else |
| | 237 | | { |
| 0 | 238 | | HostLogger.Warning("[KestrunHost] Module path does not exist: {ModPath}", modPath); |
| | 239 | | } |
| | 240 | | } |
| | 241 | | else |
| | 242 | | { |
| 0 | 243 | | HostLogger.Warning("[KestrunHost] Invalid module path provided."); |
| | 244 | | } |
| | 245 | | } |
| | 246 | | } |
| 138 | 247 | | } |
| | 248 | | #endregion |
| | 249 | |
|
| | 250 | |
|
| | 251 | | #region ListenerOptions |
| | 252 | |
|
| | 253 | | /// <summary> |
| | 254 | | /// Configures a listener for the Kestrun host with the specified port, optional IP address, certificate, protocols, |
| | 255 | | /// </summary> |
| | 256 | | /// <param name="port">The port number to listen on.</param> |
| | 257 | | /// <param name="ipAddress">The IP address to bind to. If null, binds to any address.</param> |
| | 258 | | /// <param name="x509Certificate">The X509 certificate for HTTPS. If null, HTTPS is not used.</param> |
| | 259 | | /// <param name="protocols">The HTTP protocols to use.</param> |
| | 260 | | /// <param name="useConnectionLogging">Specifies whether to enable connection logging.</param> |
| | 261 | | /// <returns>The current KestrunHost instance.</returns> |
| | 262 | | public KestrunHost ConfigureListener(int port, IPAddress? ipAddress = null, X509Certificate2? x509Certificate = null |
| | 263 | | { |
| 15 | 264 | | if (HostLogger.IsEnabled(LogEventLevel.Debug)) |
| | 265 | | { |
| 6 | 266 | | HostLogger.Debug("ConfigureListener port={Port}, ipAddress={IPAddress}, protocols={Protocols}, useConnection |
| | 267 | | } |
| | 268 | |
|
| 15 | 269 | | if (protocols == HttpProtocols.Http1AndHttp2AndHttp3 && !CcUtilities.PreviewFeaturesEnabled()) |
| | 270 | | { |
| 2 | 271 | | HostLogger.Warning("Http3 is not supported in this version of Kestrun. Using Http1 and Http2 only."); |
| 2 | 272 | | protocols = HttpProtocols.Http1AndHttp2; |
| | 273 | | } |
| | 274 | |
|
| 15 | 275 | | Options.Listeners.Add(new ListenerOptions |
| 15 | 276 | | { |
| 15 | 277 | | IPAddress = ipAddress ?? IPAddress.Any, |
| 15 | 278 | | Port = port, |
| 15 | 279 | | UseHttps = x509Certificate != null, |
| 15 | 280 | | X509Certificate = x509Certificate, |
| 15 | 281 | | Protocols = protocols, |
| 15 | 282 | | UseConnectionLogging = useConnectionLogging |
| 15 | 283 | | }); |
| 15 | 284 | | return this; |
| | 285 | | } |
| | 286 | |
|
| | 287 | | /// <summary> |
| | 288 | | /// Configures a listener for the Kestrun host with the specified port, optional IP address, and connection logging. |
| | 289 | | /// </summary> |
| | 290 | | /// <param name="port">The port number to listen on.</param> |
| | 291 | | /// <param name="ipAddress">The IP address to bind to. If null, binds to any address.</param> |
| | 292 | | /// <param name="useConnectionLogging">Specifies whether to enable connection logging.</param> |
| 9 | 293 | | public void ConfigureListener(int port, IPAddress? ipAddress = null, bool useConnectionLogging = false) => _ = Confi |
| | 294 | |
|
| | 295 | | /// <summary> |
| | 296 | | /// Configures a listener for the Kestrun host with the specified port and connection logging option. |
| | 297 | | /// </summary> |
| | 298 | | /// <param name="port">The port number to listen on.</param> |
| | 299 | | /// <param name="useConnectionLogging">Specifies whether to enable connection logging.</param> |
| 1 | 300 | | public void ConfigureListener(int port, bool useConnectionLogging = false) => _ = ConfigureListener(port: port, ipAd |
| | 301 | |
|
| | 302 | | #endregion |
| | 303 | |
|
| | 304 | |
|
| | 305 | |
|
| | 306 | |
|
| | 307 | | #region C# |
| | 308 | |
|
| | 309 | |
|
| | 310 | | #endregion |
| | 311 | |
|
| | 312 | |
|
| | 313 | |
|
| | 314 | | #region Route |
| | 315 | |
|
| | 316 | |
|
| | 317 | |
|
| | 318 | | #endregion |
| | 319 | | #region Configuration |
| | 320 | |
|
| | 321 | |
|
| | 322 | | /// <summary> |
| | 323 | | /// Applies the configured options to the Kestrel server and initializes the runspace pool. |
| | 324 | | /// </summary> |
| | 325 | | public void EnableConfiguration(Dictionary<string, object>? userVariables = null, Dictionary<string, string>? userFu |
| | 326 | | { |
| 29 | 327 | | if (HostLogger.IsEnabled(LogEventLevel.Debug)) |
| | 328 | | { |
| 15 | 329 | | HostLogger.Debug("EnableConfiguration(options) called"); |
| | 330 | | } |
| | 331 | |
|
| 29 | 332 | | if (IsConfigured) |
| | 333 | | { |
| 7 | 334 | | if (HostLogger.IsEnabled(LogEventLevel.Debug)) |
| | 335 | | { |
| 0 | 336 | | HostLogger.Debug("Configuration already applied, skipping"); |
| | 337 | | } |
| | 338 | |
|
| 7 | 339 | | return; // Already configured |
| | 340 | | } |
| | 341 | | try |
| | 342 | | { |
| | 343 | | // This method is called to apply the configured options to the Kestrel server. |
| | 344 | | // The actual application of options is done in the Run method. |
| 22 | 345 | | _runspacePool = CreateRunspacePool(Options.MaxRunspaces, userVariables, userFunctions); |
| 22 | 346 | | if (_runspacePool == null) |
| | 347 | | { |
| 0 | 348 | | throw new InvalidOperationException("Failed to create runspace pool."); |
| | 349 | | } |
| | 350 | |
|
| 22 | 351 | | if (HostLogger.IsEnabled(LogEventLevel.Verbose)) |
| | 352 | | { |
| 0 | 353 | | HostLogger.Verbose("Runspace pool created with max runspaces: {MaxRunspaces}", Options.MaxRunspaces); |
| | 354 | | } |
| | 355 | | // Configure Kestrel |
| 22 | 356 | | _ = Builder.WebHost.UseKestrel(opts => |
| 22 | 357 | | { |
| 22 | 358 | | opts.CopyFromTemplate(Options.ServerOptions); |
| 44 | 359 | | }); |
| | 360 | |
|
| 22 | 361 | | if (Options.NamedPipeOptions is not null) |
| | 362 | | { |
| 0 | 363 | | if (OperatingSystem.IsWindows()) |
| | 364 | | { |
| 0 | 365 | | _ = Builder.WebHost.UseNamedPipes(opts => |
| 0 | 366 | | { |
| 0 | 367 | | opts.ListenerQueueCount = Options.NamedPipeOptions.ListenerQueueCount; |
| 0 | 368 | | opts.MaxReadBufferSize = Options.NamedPipeOptions.MaxReadBufferSize; |
| 0 | 369 | | opts.MaxWriteBufferSize = Options.NamedPipeOptions.MaxWriteBufferSize; |
| 0 | 370 | | opts.CurrentUserOnly = Options.NamedPipeOptions.CurrentUserOnly; |
| 0 | 371 | | opts.PipeSecurity = Options.NamedPipeOptions.PipeSecurity; |
| 0 | 372 | | }); |
| | 373 | | } |
| | 374 | | else |
| | 375 | | { |
| 0 | 376 | | HostLogger.Verbose("Named pipe listeners are supported only on Windows; skipping UseNamedPipes confi |
| | 377 | | } |
| | 378 | | } |
| | 379 | |
|
| | 380 | | // Apply Kestrel listeners and HTTPS settings |
| 22 | 381 | | _ = Builder.WebHost.ConfigureKestrel(serverOptions => |
| 22 | 382 | | { |
| 22 | 383 | | if (Options.HttpsConnectionAdapter is not null) |
| 22 | 384 | | { |
| 0 | 385 | | HostLogger.Verbose("Applying HTTPS connection adapter options from KestrunOptions."); |
| 22 | 386 | |
|
| 22 | 387 | | // Apply HTTPS defaults if needed |
| 0 | 388 | | serverOptions.ConfigureHttpsDefaults(httpsOptions => |
| 0 | 389 | | { |
| 0 | 390 | | httpsOptions.SslProtocols = Options.HttpsConnectionAdapter.SslProtocols; |
| 0 | 391 | | httpsOptions.ClientCertificateMode = Options.HttpsConnectionAdapter.ClientCertificateMode; |
| 0 | 392 | | httpsOptions.ClientCertificateValidation = Options.HttpsConnectionAdapter.ClientCertificateValid |
| 0 | 393 | | httpsOptions.CheckCertificateRevocation = Options.HttpsConnectionAdapter.CheckCertificateRevocat |
| 0 | 394 | | httpsOptions.ServerCertificate = Options.HttpsConnectionAdapter.ServerCertificate; |
| 0 | 395 | | httpsOptions.ServerCertificateChain = Options.HttpsConnectionAdapter.ServerCertificateChain; |
| 0 | 396 | | httpsOptions.ServerCertificateSelector = Options.HttpsConnectionAdapter.ServerCertificateSelecto |
| 0 | 397 | | httpsOptions.HandshakeTimeout = Options.HttpsConnectionAdapter.HandshakeTimeout; |
| 0 | 398 | | httpsOptions.OnAuthenticate = Options.HttpsConnectionAdapter.OnAuthenticate; |
| 0 | 399 | | }); |
| 22 | 400 | | } |
| 22 | 401 | | // Unix domain socket listeners |
| 44 | 402 | | foreach (var unixSocket in Options.ListenUnixSockets) |
| 22 | 403 | | { |
| 0 | 404 | | if (!string.IsNullOrWhiteSpace(unixSocket)) |
| 22 | 405 | | { |
| 0 | 406 | | HostLogger.Verbose("Binding Unix socket: {Sock}", unixSocket); |
| 0 | 407 | | serverOptions.ListenUnixSocket(unixSocket); |
| 22 | 408 | | // NOTE: control access via directory perms/umask; UDS file perms are inherited from process uma |
| 22 | 409 | | // Prefer placing the socket under a group-owned dir (e.g., /var/run/kestrun) with 0770. |
| 22 | 410 | | } |
| 22 | 411 | | } |
| 22 | 412 | |
|
| 22 | 413 | | // Named pipe listeners |
| 44 | 414 | | foreach (var namedPipeName in Options.NamedPipeNames) |
| 22 | 415 | | { |
| 0 | 416 | | if (!string.IsNullOrWhiteSpace(namedPipeName)) |
| 22 | 417 | | { |
| 0 | 418 | | HostLogger.Verbose("Binding Named Pipe: {Pipe}", namedPipeName); |
| 0 | 419 | | serverOptions.ListenNamedPipe(namedPipeName); |
| 22 | 420 | | } |
| 22 | 421 | | } |
| 22 | 422 | |
|
| 22 | 423 | | // TCP listeners |
| 60 | 424 | | foreach (var opt in Options.Listeners) |
| 22 | 425 | | { |
| 8 | 426 | | serverOptions.Listen(opt.IPAddress, opt.Port, listenOptions => |
| 8 | 427 | | { |
| 8 | 428 | | listenOptions.Protocols = opt.Protocols; |
| 8 | 429 | | listenOptions.DisableAltSvcHeader = opt.DisableAltSvcHeader; |
| 8 | 430 | | if (opt.UseHttps && opt.X509Certificate is not null) |
| 8 | 431 | | { |
| 0 | 432 | | _ = listenOptions.UseHttps(opt.X509Certificate); |
| 8 | 433 | | } |
| 8 | 434 | | if (opt.UseConnectionLogging) |
| 8 | 435 | | { |
| 0 | 436 | | _ = listenOptions.UseConnectionLogging(); |
| 8 | 437 | | } |
| 16 | 438 | | }); |
| 22 | 439 | | } |
| 44 | 440 | | }); |
| | 441 | |
|
| | 442 | | // build the app to validate configuration |
| 22 | 443 | | _app = Build(); |
| 22 | 444 | | var dataSource = _app.Services.GetRequiredService<EndpointDataSource>(); |
| | 445 | |
|
| 22 | 446 | | if (dataSource.Endpoints.Count == 0) |
| | 447 | | { |
| 22 | 448 | | HostLogger.Warning("EndpointDataSource is empty. No endpoints configured."); |
| | 449 | | } |
| | 450 | | else |
| | 451 | | { |
| 0 | 452 | | foreach (var ep in dataSource.Endpoints) |
| | 453 | | { |
| 0 | 454 | | HostLogger.Information("➡️ Endpoint: {DisplayName}", ep.DisplayName); |
| | 455 | | } |
| | 456 | | } |
| | 457 | |
|
| 22 | 458 | | IsConfigured = true; |
| 22 | 459 | | HostLogger.Information("Configuration applied successfully."); |
| 22 | 460 | | } |
| 0 | 461 | | catch (Exception ex) |
| | 462 | | { |
| 0 | 463 | | HostLogger.Error(ex, "Error applying configuration: {Message}", ex.Message); |
| 0 | 464 | | throw new InvalidOperationException("Failed to apply configuration.", ex); |
| | 465 | | } |
| 22 | 466 | | } |
| | 467 | |
|
| | 468 | | #endregion |
| | 469 | | #region Builder |
| | 470 | | /* More information about the KestrunHost class |
| | 471 | | https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.builder.webapplication?view=aspnetcore-8.0 |
| | 472 | |
|
| | 473 | | */ |
| | 474 | |
|
| | 475 | | /// <summary> |
| | 476 | | /// Builds the WebApplication. |
| | 477 | | /// This method applies all queued services and middleware stages, |
| | 478 | | /// and returns the built WebApplication instance. |
| | 479 | | /// </summary> |
| | 480 | | /// <returns>The built WebApplication.</returns> |
| | 481 | | /// <exception cref="InvalidOperationException"></exception> |
| | 482 | | public WebApplication Build() |
| | 483 | | { |
| 45 | 484 | | if (Builder == null) |
| | 485 | | { |
| 0 | 486 | | throw new InvalidOperationException("Call CreateBuilder() first."); |
| | 487 | | } |
| | 488 | |
|
| | 489 | | // 1️⃣ Apply all queued services |
| 170 | 490 | | foreach (var configure in _serviceQueue) |
| | 491 | | { |
| 40 | 492 | | configure(Builder.Services); |
| | 493 | | } |
| | 494 | |
|
| | 495 | | // 2️⃣ Build the WebApplication |
| 45 | 496 | | _app = Builder.Build(); |
| | 497 | |
|
| 45 | 498 | | HostLogger.Information("CWD: {CWD}", Directory.GetCurrentDirectory()); |
| 45 | 499 | | HostLogger.Information("ContentRoot: {Root}", _app.Environment.ContentRootPath); |
| 45 | 500 | | var pagesDir = Path.Combine(_app.Environment.ContentRootPath, "Pages"); |
| 45 | 501 | | HostLogger.Information("Pages Dir: {PagesDir}", pagesDir); |
| 45 | 502 | | if (Directory.Exists(pagesDir)) |
| | 503 | | { |
| 0 | 504 | | foreach (var file in Directory.GetFiles(pagesDir, "*.*", SearchOption.AllDirectories)) |
| | 505 | | { |
| 0 | 506 | | HostLogger.Information("Pages file: {File}", file); |
| | 507 | | } |
| | 508 | | } |
| | 509 | | else |
| | 510 | | { |
| 45 | 511 | | HostLogger.Warning("Pages directory does not exist: {PagesDir}", pagesDir); |
| | 512 | | } |
| | 513 | |
|
| | 514 | | // 3️⃣ Apply all queued middleware stages |
| 160 | 515 | | foreach (var stage in _middlewareQueue) |
| | 516 | | { |
| 35 | 517 | | stage(_app); |
| | 518 | | } |
| | 519 | |
|
| 94 | 520 | | foreach (var feature in FeatureQueue) |
| | 521 | | { |
| 2 | 522 | | feature(this); |
| | 523 | | } |
| | 524 | | // 5️⃣ Terminal endpoint execution |
| 45 | 525 | | return _app; |
| | 526 | | } |
| | 527 | |
|
| | 528 | | /// <summary> |
| | 529 | | /// Adds a service configuration action to the service queue. |
| | 530 | | /// This action will be executed when the services are built. |
| | 531 | | /// </summary> |
| | 532 | | /// <param name="configure">The service configuration action.</param> |
| | 533 | | /// <returns>The current KestrunHost instance.</returns> |
| | 534 | | public KestrunHost AddService(Action<IServiceCollection> configure) |
| | 535 | | { |
| 58 | 536 | | _serviceQueue.Add(configure); |
| 58 | 537 | | return this; |
| | 538 | | } |
| | 539 | |
|
| | 540 | | /// <summary> |
| | 541 | | /// Adds a middleware stage to the application pipeline. |
| | 542 | | /// </summary> |
| | 543 | | /// <param name="stage">The middleware stage to add.</param> |
| | 544 | | /// <returns>The current KestrunHost instance.</returns> |
| | 545 | | public KestrunHost Use(Action<IApplicationBuilder> stage) |
| | 546 | | { |
| 69 | 547 | | _middlewareQueue.Add(stage); |
| 69 | 548 | | return this; |
| | 549 | | } |
| | 550 | |
|
| | 551 | | /// <summary> |
| | 552 | | /// Adds a feature configuration action to the feature queue. |
| | 553 | | /// This action will be executed when the features are applied. |
| | 554 | | /// </summary> |
| | 555 | | /// <param name="feature">The feature configuration action.</param> |
| | 556 | | /// <returns>The current KestrunHost instance.</returns> |
| | 557 | | public KestrunHost AddFeature(Action<KestrunHost> feature) |
| | 558 | | { |
| 2 | 559 | | FeatureQueue.Add(feature); |
| 2 | 560 | | return this; |
| | 561 | | } |
| | 562 | |
|
| | 563 | | /// <summary> |
| | 564 | | /// Adds a scheduling feature to the Kestrun host, optionally specifying the maximum number of runspaces for the sch |
| | 565 | | /// </summary> |
| | 566 | | /// <param name="MaxRunspaces">The maximum number of runspaces for the scheduler. If null, uses the default value.</ |
| | 567 | | /// <returns>The current KestrunHost instance.</returns> |
| | 568 | | public KestrunHost AddScheduling(int? MaxRunspaces = null) |
| | 569 | | { |
| 4 | 570 | | return MaxRunspaces is not null and <= 0 |
| 4 | 571 | | ? throw new ArgumentOutOfRangeException(nameof(MaxRunspaces), "MaxRunspaces must be greater than zero.") |
| 4 | 572 | | : AddFeature(host => |
| 4 | 573 | | { |
| 2 | 574 | | if (HostLogger.IsEnabled(LogEventLevel.Debug)) |
| 4 | 575 | | { |
| 2 | 576 | | HostLogger.Debug("AddScheduling (deferred)"); |
| 4 | 577 | | } |
| 4 | 578 | |
|
| 2 | 579 | | if (host.Scheduler is null) |
| 4 | 580 | | { |
| 1 | 581 | | if (MaxRunspaces is not null and > 0) |
| 4 | 582 | | { |
| 1 | 583 | | HostLogger.Information("Setting MaxSchedulerRunspaces to {MaxRunspaces}", MaxRunspaces); |
| 1 | 584 | | host.Options.MaxSchedulerRunspaces = MaxRunspaces.Value; |
| 4 | 585 | | } |
| 1 | 586 | | HostLogger.Verbose("Creating SchedulerService with MaxSchedulerRunspaces={MaxRunspaces}", |
| 1 | 587 | | host.Options.MaxSchedulerRunspaces); |
| 1 | 588 | | var pool = host.CreateRunspacePool(host.Options.MaxSchedulerRunspaces); |
| 1 | 589 | | var logger = HostLogger.ForContext<KestrunHost>(); |
| 1 | 590 | | host.Scheduler = new SchedulerService(pool, logger); |
| 4 | 591 | | } |
| 4 | 592 | | else |
| 4 | 593 | | { |
| 1 | 594 | | HostLogger.Warning("SchedulerService already configured; skipping."); |
| 4 | 595 | | } |
| 5 | 596 | | }); |
| | 597 | | } |
| | 598 | |
|
| | 599 | | /// <summary> |
| | 600 | | /// Adds MVC / API controllers to the application. |
| | 601 | | /// </summary> |
| | 602 | | /// <param name="cfg">The configuration options for MVC / API controllers.</param> |
| | 603 | | /// <returns>The current KestrunHost instance.</returns> |
| | 604 | | public KestrunHost AddControllers(Action<Microsoft.AspNetCore.Mvc.MvcOptions>? cfg = null) |
| | 605 | | { |
| 0 | 606 | | return AddService(services => |
| 0 | 607 | | { |
| 0 | 608 | | var builder = services.AddControllers(); |
| 0 | 609 | | if (cfg != null) |
| 0 | 610 | | { |
| 0 | 611 | | _ = builder.ConfigureApplicationPartManager(pm => { }); // customise if you wish |
| 0 | 612 | | } |
| 0 | 613 | | }); |
| | 614 | | } |
| | 615 | |
|
| | 616 | |
|
| | 617 | |
|
| | 618 | |
|
| | 619 | | /// <summary> |
| | 620 | | /// Adds a PowerShell runtime to the application. |
| | 621 | | /// This middleware allows you to execute PowerShell scripts in response to HTTP requests. |
| | 622 | | /// </summary> |
| | 623 | | /// <param name="routePrefix">The route prefix to use for the PowerShell runtime.</param> |
| | 624 | | /// <returns>The current KestrunHost instance.</returns> |
| | 625 | | public KestrunHost AddPowerShellRuntime(PathString? routePrefix = null) |
| | 626 | | { |
| 1 | 627 | | if (HostLogger.IsEnabled(LogEventLevel.Debug)) |
| | 628 | | { |
| 1 | 629 | | HostLogger.Debug("Adding PowerShell runtime with route prefix: {RoutePrefix}", routePrefix); |
| | 630 | | } |
| | 631 | |
|
| 1 | 632 | | return Use(app => |
| 1 | 633 | | { |
| 1 | 634 | | ArgumentNullException.ThrowIfNull(_runspacePool); |
| 1 | 635 | | // ── mount PowerShell at the root ── |
| 1 | 636 | | _ = app.UseLanguageRuntime( |
| 1 | 637 | | ScriptLanguage.PowerShell, |
| 2 | 638 | | b => b.UsePowerShellRunspace(_runspacePool)); |
| 2 | 639 | | }); |
| | 640 | | } |
| | 641 | |
|
| | 642 | |
|
| | 643 | |
|
| | 644 | |
|
| | 645 | |
|
| | 646 | |
|
| | 647 | |
|
| | 648 | | // ② SignalR |
| | 649 | | /// <summary> |
| | 650 | | /// Adds a SignalR hub to the application at the specified path. |
| | 651 | | /// </summary> |
| | 652 | | /// <typeparam name="T">The type of the SignalR hub.</typeparam> |
| | 653 | | /// <param name="path">The path at which to map the SignalR hub.</param> |
| | 654 | | /// <returns>The current KestrunHost instance.</returns> |
| | 655 | | public KestrunHost AddSignalR<T>(string path) where T : Hub |
| | 656 | | { |
| 0 | 657 | | return AddService(s => s.AddSignalR()) |
| 0 | 658 | | .Use(app => ((IEndpointRouteBuilder)app).MapHub<T>(path)); |
| | 659 | | } |
| | 660 | |
|
| | 661 | | /* |
| | 662 | | // ④ gRPC |
| | 663 | | public KestrunHost AddGrpc<TService>() where TService : class |
| | 664 | | { |
| | 665 | | return AddService(s => s.AddGrpc()) |
| | 666 | | .Use(app => app.MapGrpcService<TService>()); |
| | 667 | | } |
| | 668 | | */ |
| | 669 | |
|
| | 670 | | /* public KestrunHost AddSwagger() |
| | 671 | | { |
| | 672 | | AddService(s => |
| | 673 | | { |
| | 674 | | s.AddEndpointsApiExplorer(); |
| | 675 | | s.AddSwaggerGen(); |
| | 676 | | }); |
| | 677 | | // ⚠️ Swagger’s middleware normally goes first in the pipeline |
| | 678 | | return Use(app => |
| | 679 | | { |
| | 680 | | app.UseSwagger(); |
| | 681 | | app.UseSwaggerUI(); |
| | 682 | | }); |
| | 683 | | }*/ |
| | 684 | |
|
| | 685 | | // Add as many tiny helpers as you wish: |
| | 686 | | // • AddAuthentication(jwt => { … }) |
| | 687 | | // • AddSignalR() |
| | 688 | | // • AddHealthChecks() |
| | 689 | | // • AddGrpc() |
| | 690 | | // etc. |
| | 691 | |
|
| | 692 | | #endregion |
| | 693 | | #region Run/Start/Stop |
| | 694 | |
|
| | 695 | | /// <summary> |
| | 696 | | /// Runs the Kestrun web application, applying configuration and starting the server. |
| | 697 | | /// </summary> |
| | 698 | | public void Run() |
| | 699 | | { |
| 0 | 700 | | if (HostLogger.IsEnabled(LogEventLevel.Debug)) |
| | 701 | | { |
| 0 | 702 | | HostLogger.Debug("Run() called"); |
| | 703 | | } |
| | 704 | |
|
| 0 | 705 | | EnableConfiguration(); |
| | 706 | |
|
| 0 | 707 | | _app?.Run(); |
| 0 | 708 | | } |
| | 709 | |
|
| | 710 | | /// <summary> |
| | 711 | | /// Starts the Kestrun web application asynchronously. |
| | 712 | | /// </summary> |
| | 713 | | /// <param name="cancellationToken">A cancellation token to observe while waiting for the task to complete.</param> |
| | 714 | | /// <returns>A task that represents the asynchronous start operation.</returns> |
| | 715 | | public async Task StartAsync(CancellationToken cancellationToken = default) |
| | 716 | | { |
| 8 | 717 | | if (HostLogger.IsEnabled(LogEventLevel.Debug)) |
| | 718 | | { |
| 1 | 719 | | HostLogger.Debug("StartAsync() called"); |
| | 720 | | } |
| | 721 | |
|
| 8 | 722 | | EnableConfiguration(); |
| 8 | 723 | | if (_app != null) |
| | 724 | | { |
| 8 | 725 | | await _app.StartAsync(cancellationToken); |
| | 726 | | } |
| 8 | 727 | | } |
| | 728 | |
|
| | 729 | | /// <summary> |
| | 730 | | /// Stops the Kestrun web application asynchronously. |
| | 731 | | /// </summary> |
| | 732 | | /// <param name="cancellationToken">A cancellation token to observe while waiting for the task to complete.</param> |
| | 733 | | /// <returns>A task that represents the asynchronous stop operation.</returns> |
| | 734 | | public async Task StopAsync(CancellationToken cancellationToken = default) |
| | 735 | | { |
| 13 | 736 | | if (HostLogger.IsEnabled(LogEventLevel.Debug)) |
| | 737 | | { |
| 6 | 738 | | HostLogger.Debug("StopAsync() called"); |
| | 739 | | } |
| | 740 | |
|
| 13 | 741 | | if (_app != null) |
| | 742 | | { |
| | 743 | | try |
| | 744 | | { |
| | 745 | | // Initiate graceful shutdown |
| 8 | 746 | | await _app.StopAsync(cancellationToken); |
| 8 | 747 | | } |
| 0 | 748 | | catch (Exception ex) when (ex.GetType().FullName == "System.Net.Quic.QuicException") |
| | 749 | | { |
| | 750 | | // QUIC exceptions can occur during shutdown, especially if the server is not using QUIC. |
| | 751 | | // We log this as a debug message to avoid cluttering the logs with expected exceptions. |
| | 752 | | // This is a workaround for |
| | 753 | |
|
| 0 | 754 | | HostLogger.Debug("Ignored QUIC exception during shutdown: {Message}", ex.Message); |
| 0 | 755 | | } |
| | 756 | | } |
| 13 | 757 | | } |
| | 758 | |
|
| | 759 | | /// <summary> |
| | 760 | | /// Initiates a graceful shutdown of the Kestrun web application. |
| | 761 | | /// </summary> |
| | 762 | | public void Stop() |
| | 763 | | { |
| 1 | 764 | | if (HostLogger.IsEnabled(LogEventLevel.Debug)) |
| | 765 | | { |
| 1 | 766 | | HostLogger.Debug("Stop() called"); |
| | 767 | | } |
| | 768 | | // This initiates a graceful shutdown. |
| 1 | 769 | | _app?.Lifetime.StopApplication(); |
| 1 | 770 | | } |
| | 771 | |
|
| | 772 | | /// <summary> |
| | 773 | | /// Determines whether the Kestrun web application is currently running. |
| | 774 | | /// </summary> |
| | 775 | | /// <returns>True if the application is running; otherwise, false.</returns> |
| | 776 | | public bool IsRunning |
| | 777 | | { |
| | 778 | | get |
| | 779 | | { |
| 8 | 780 | | var appField = typeof(KestrunHost) |
| 8 | 781 | | .GetField("_app", BindingFlags.NonPublic | BindingFlags.Instance); |
| | 782 | |
|
| 8 | 783 | | return appField?.GetValue(this) is WebApplication app && !app.Lifetime.ApplicationStopping.IsCancellationReq |
| | 784 | | } |
| | 785 | | } |
| | 786 | |
|
| | 787 | |
|
| | 788 | | #endregion |
| | 789 | |
|
| | 790 | |
|
| | 791 | |
|
| | 792 | | #region Runspace Pool Management |
| | 793 | |
|
| | 794 | | /// <summary> |
| | 795 | | /// Creates and returns a new <see cref="KestrunRunspacePoolManager"/> instance with the specified maximum number of |
| | 796 | | /// </summary> |
| | 797 | | /// <param name="maxRunspaces">The maximum number of runspaces to create. If not specified or zero, defaults to twic |
| | 798 | | /// <param name="userVariables">A dictionary of user-defined variables to inject into the runspace pool.</param> |
| | 799 | | /// <param name="userFunctions">A dictionary of user-defined functions to inject into the runspace pool.</param> |
| | 800 | | /// <returns>A configured <see cref="KestrunRunspacePoolManager"/> instance.</returns> |
| | 801 | | public KestrunRunspacePoolManager CreateRunspacePool(int? maxRunspaces = 0, Dictionary<string, object>? userVariable |
| | 802 | | { |
| 23 | 803 | | if (HostLogger.IsEnabled(LogEventLevel.Debug)) |
| | 804 | | { |
| 16 | 805 | | HostLogger.Debug("CreateRunspacePool() called: {@MaxRunspaces}", maxRunspaces); |
| | 806 | | } |
| | 807 | |
|
| | 808 | | // Create a default InitialSessionState with an unrestricted policy: |
| 23 | 809 | | var iss = InitialSessionState.CreateDefault(); |
| | 810 | |
|
| 23 | 811 | | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) |
| | 812 | | { |
| 0 | 813 | | iss.ExecutionPolicy = ExecutionPolicy.Unrestricted; |
| | 814 | | } |
| | 815 | |
|
| 92 | 816 | | foreach (var p in _modulePaths) |
| | 817 | | { |
| 23 | 818 | | iss.ImportPSModule([p]); |
| | 819 | | } |
| 23 | 820 | | iss.Variables.Add( |
| 23 | 821 | | new SessionStateVariableEntry( |
| 23 | 822 | | "KestrunHost", |
| 23 | 823 | | this, |
| 23 | 824 | | "The KestrunHost instance" |
| 23 | 825 | | ) |
| 23 | 826 | | ); |
| | 827 | | // Inject global variables into all runspaces |
| 276 | 828 | | foreach (var kvp in SharedStateStore.Snapshot()) |
| | 829 | | { |
| | 830 | | // kvp.Key = "Visits", kvp.Value = 0 |
| 115 | 831 | | iss.Variables.Add( |
| 115 | 832 | | new SessionStateVariableEntry( |
| 115 | 833 | | kvp.Key, |
| 115 | 834 | | kvp.Value, |
| 115 | 835 | | "Global variable" |
| 115 | 836 | | ) |
| 115 | 837 | | ); |
| | 838 | | } |
| | 839 | |
|
| 46 | 840 | | foreach (var kvp in userVariables ?? []) |
| | 841 | | { |
| 0 | 842 | | if (kvp.Value is PSVariable psVar) |
| | 843 | | { |
| 0 | 844 | | iss.Variables.Add( |
| 0 | 845 | | new SessionStateVariableEntry( |
| 0 | 846 | | kvp.Key, |
| 0 | 847 | | psVar.Value, |
| 0 | 848 | | psVar.Description ?? "User-defined variable" |
| 0 | 849 | | ) |
| 0 | 850 | | ); |
| | 851 | | } |
| | 852 | | else |
| | 853 | | { |
| 0 | 854 | | iss.Variables.Add( |
| 0 | 855 | | new SessionStateVariableEntry( |
| 0 | 856 | | kvp.Key, |
| 0 | 857 | | kvp.Value, |
| 0 | 858 | | "User-defined variable" |
| 0 | 859 | | ) |
| 0 | 860 | | ); |
| | 861 | | } |
| | 862 | | } |
| | 863 | |
|
| 46 | 864 | | foreach (var r in userFunctions ?? []) |
| | 865 | | { |
| 0 | 866 | | var name = r.Key; |
| 0 | 867 | | var def = r.Value; |
| | 868 | |
|
| | 869 | | // Use the string-based ctor available in 7.4 ref/net8.0 |
| 0 | 870 | | var entry = new SessionStateFunctionEntry( |
| 0 | 871 | | name, |
| 0 | 872 | | def, |
| 0 | 873 | | ScopedItemOptions.ReadOnly, // or ScopedItemOptions.None if you want them mutable |
| 0 | 874 | | helpFile: null |
| 0 | 875 | | ); |
| | 876 | |
|
| 0 | 877 | | iss.Commands.Add(entry); |
| | 878 | | } |
| | 879 | |
|
| | 880 | | // Determine max runspaces |
| 23 | 881 | | var maxRs = (maxRunspaces.HasValue && maxRunspaces.Value > 0) ? maxRunspaces.Value : Environment.ProcessorCount |
| | 882 | |
|
| 23 | 883 | | HostLogger.Information($"Creating runspace pool with max runspaces: {maxRs}"); |
| 23 | 884 | | var runspacePool = new KestrunRunspacePoolManager(Options?.MinRunspaces ?? 1, maxRunspaces: maxRs, initialSessio |
| | 885 | | // Return the created runspace pool |
| 23 | 886 | | return runspacePool; |
| | 887 | | } |
| | 888 | |
|
| | 889 | |
|
| | 890 | | #endregion |
| | 891 | |
|
| | 892 | |
|
| | 893 | | #region Disposable |
| | 894 | |
|
| | 895 | | /// <summary> |
| | 896 | | /// Releases all resources used by the <see cref="KestrunHost"/> instance. |
| | 897 | | /// </summary> |
| | 898 | | public void Dispose() |
| | 899 | | { |
| 21 | 900 | | if (HostLogger.IsEnabled(LogEventLevel.Debug)) |
| | 901 | | { |
| 21 | 902 | | HostLogger.Debug("Dispose() called"); |
| | 903 | | } |
| | 904 | |
|
| 21 | 905 | | _runspacePool?.Dispose(); |
| 21 | 906 | | _runspacePool = null; // Clear the runspace pool reference |
| 21 | 907 | | IsConfigured = false; // Reset configuration state |
| 21 | 908 | | _app = null; |
| 21 | 909 | | Scheduler?.Dispose(); |
| 21 | 910 | | (HostLogger as IDisposable)?.Dispose(); |
| 21 | 911 | | GC.SuppressFinalize(this); |
| 21 | 912 | | } |
| | 913 | | #endregion |
| | 914 | |
|
| | 915 | | #region Script Validation |
| | 916 | |
|
| | 917 | |
|
| | 918 | | #endregion |
| | 919 | | } |