Add project files.
4085963e133146b9a4b6b87cbe3655f56e8ab2a5
13 files changed
SessionScoped/SessionScoped.csprojSessionScoped/SessionScopedExtension.csSessionScoped/SessionScopedFactory.csSessionScoped/SessionScopedWrapper.csSessionScopedExtension.slnSessionScopedTest/Controllers/ExampleController.csSessionScopedTest/Program.csSessionScopedTest/Properties/launchSettings.jsonSessionScopedTest/Services/ExampleService.csSessionScopedTest/SessionScopedTest.csprojSessionScopedTest/appsettings.Development.jsonSessionScopedTest/appsettings.jsonSessionScopedTest/wwwroot/Index.html
diff --git a/SessionScoped/SessionScoped.csproj b/SessionScoped/SessionScoped.csproj
new file mode 100644
index 0000000..5ac55a4
--- /dev/null
+++ b/SessionScoped/SessionScoped.csproj
@@ -0,0 +1,21 @@
+<Project Sdk="Microsoft.NET.Sdk">
+ <PropertyGroup>
+ <TargetFramework>net9.0</TargetFramework>
+ <ImplicitUsings>enable</ImplicitUsings>
+ <Nullable>enable</Nullable>
+ <PackageId>Microsoft.Extensions.DependencyInjection.SessionScoped</PackageId>
+ <Version>9.0.0</Version>
+ <Authors>Alphons van der Heijden</Authors>
+ <Description>Adds a session-scoped IHostedService service to the services collection</Description>
+ <PackageLicenseExpression>MIT</PackageLicenseExpression>
+ <RepositoryUrl>https://github.com/alphons</RepositoryUrl>
+ <PackageTags>DependencyInjection;SessionScoped</PackageTags>
+ <GeneratePackageOnBuild>true</GeneratePackageOnBuild>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <PackageReference Include="Microsoft.AspNetCore.Session" Version="2.3.0" />
+ <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="9.0.1" />
+ </ItemGroup>
+
+</Project>
diff --git a/SessionScoped/SessionScopedExtension.cs b/SessionScoped/SessionScopedExtension.cs
new file mode 100644
index 0000000..6051e8a
--- /dev/null
+++ b/SessionScoped/SessionScopedExtension.cs
@@ -0,0 +1,24 @@
+using Microsoft.Extensions.Hosting;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+/// <summary>
+/// Extension method to add session-scoped services to the dependency injection container.
+/// </summary>
+public static class SessionScopedExtension
+{
+ /// <summary>
+ /// Adds a session-scoped IHostedService service to the services collection.
+ /// </summary>
+ /// <typeparam name="T">The type of the hosted service, which must implement <see cref="IHostedService" />.</typeparam>
+ /// <param name="services">The service collection to add the service to.</param>
+ /// <returns>The updated service collection.</returns>
+ public static IServiceCollection AddSessionScoped<T>(this IServiceCollection services) where T : class, IHostedService
+ {
+ services.AddScoped<T>();
+ services.AddSingleton<SessionScopedFactory<T>>();
+ services.AddSingleton<IHostedService>(provider => provider.GetRequiredService<SessionScopedFactory<T>>());
+
+ return services;
+ }
+}
diff --git a/SessionScoped/SessionScopedFactory.cs b/SessionScoped/SessionScopedFactory.cs
new file mode 100644
index 0000000..dd5fc2a
--- /dev/null
+++ b/SessionScoped/SessionScopedFactory.cs
@@ -0,0 +1,143 @@
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using System.Collections.Concurrent;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+/// <summary>
+/// A background service that manages session-scoped hosted services.
+/// </summary>
+/// <typeparam name="T">The type of the hosted service.</typeparam>
+public class SessionScopedFactory<T>(
+ IServiceProvider serviceProvider,
+ IHttpContextAccessor httpContextAccessor,
+ ILoggerFactory loggerFactory) : BackgroundService where T : IHostedService
+{
+ /// <summary>
+ /// Logger for the session-scoped factory.
+ /// </summary>
+ private readonly ILogger Logger = loggerFactory.CreateLogger<SessionScopedFactory<T>>();
+
+ /// <summary>
+ /// A dictionary of session-scoped services, keyed by session ID.
+ /// </summary>
+ private readonly ConcurrentDictionary<string, SessionScopedWrapper> services = new();
+
+ /// <summary>
+ /// A dictionary to track session timeouts.
+ /// </summary>
+ private readonly ConcurrentDictionary<string, DateTimeOffset> timeout = new();
+
+ /// <summary>
+ /// Creates a wrapped instance of the scoped service.
+ /// </summary>
+ /// <returns>A wrapped session-scoped service.</returns>
+ private SessionScopedWrapper GetScopedServiceWrapped()
+ {
+ using var scope = serviceProvider.CreateScope();
+ return new(scope.ServiceProvider.GetRequiredService<T>());
+ }
+
+ /// <summary>
+ /// Gets or creates the instance of the hosted service for the current session.
+ /// </summary>
+ public T Instance
+ {
+ get
+ {
+ var sessionId = (httpContextAccessor.HttpContext?.Session?.Id) ??
+ throw new InvalidOperationException("Session ID is not available.");
+
+ timeout.AddOrUpdate(sessionId, DateTimeOffset.UtcNow,
+ (key, oldValue) => DateTimeOffset.UtcNow);
+
+ if (!services.TryGetValue(sessionId, out SessionScopedWrapper? wrapper))
+ {
+ wrapper = GetScopedServiceWrapped();
+ if (wrapper == null)
+ throw new InvalidOperationException("Cannot get scoped service.");
+ if (services.TryAdd(sessionId, wrapper))
+ {
+ wrapper.Start();
+ Logger.LogInformation("Added started service with ID: {sessionId}", sessionId);
+ }
+ }
+ return (T)wrapper.hostedService;
+ }
+ }
+
+ /// <summary>
+ /// Removes a service associated with the given session ID.
+ /// </summary>
+ /// <param name="sessionId">The session ID to remove.</param>
+ /// <returns>A task representing the asynchronous operation.</returns>
+ private async Task RemoveServiceAsync(string sessionId)
+ {
+ // Remove service if it exists
+ if (services.TryRemove(sessionId, out SessionScopedWrapper? service))
+ {
+ await service.StopAsync();
+ Logger.LogInformation("Removed stopped service with ID: {sessionId}", sessionId);
+ service = null;
+ }
+
+ // Remove the timeout entry
+ timeout.TryRemove(sessionId, out _);
+ }
+
+ /// <summary>
+ /// Stops all services and cleans up.
+ /// </summary>
+ public override async Task StopAsync(CancellationToken cancellationToken)
+ {
+ foreach (var id in services.Keys.ToArray())
+ {
+ await RemoveServiceAsync(id);
+ }
+ }
+
+ /// <summary>
+ /// Executes the background service and cleans up expired sessions.
+ /// </summary>
+ /// <param name="cancellationToken">The token to monitor for cancellation requests.</param>
+ protected override async Task ExecuteAsync(CancellationToken cancellationToken)
+ {
+ Logger.LogInformation($"{nameof(SessionScopedFactory<T>)} starting.");
+
+ var sessionOptions = serviceProvider.GetRequiredService<IOptions<SessionOptions>>();
+
+ var sessionTimeout = sessionOptions.Value.IdleTimeout;
+
+ while (!cancellationToken.IsCancellationRequested)
+ {
+ try
+ {
+ // Configurable interval duration
+ await Task.Delay(TimeSpan.FromSeconds(5), cancellationToken);
+
+ // Determine the threshold for expired items
+ var expirationThreshold = DateTimeOffset.UtcNow.Subtract(sessionTimeout);
+
+ var sessionIds = timeout.Where(x => x.Value < expirationThreshold).Select(x => x.Key).ToArray();
+
+ foreach (var sessionId in sessionIds)
+ {
+ await RemoveServiceAsync(sessionId);
+ }
+ }
+ catch (TaskCanceledException)
+ {
+ }
+ catch (Exception ex)
+ {
+ // Log errors during the loop
+ Logger.LogError(ex, "An error occurred during timeout cleanup.");
+ }
+ }
+
+ Logger.LogInformation($"{nameof(SessionScopedFactory<T>)} has stopped.");
+ }
+}
diff --git a/SessionScoped/SessionScopedWrapper.cs b/SessionScoped/SessionScopedWrapper.cs
new file mode 100644
index 0000000..0981a1e
--- /dev/null
+++ b/SessionScoped/SessionScopedWrapper.cs
@@ -0,0 +1,62 @@
+using Microsoft.Extensions.Hosting;
+
+namespace Microsoft.Extensions.DependencyInjection;
+
+/// <summary>
+/// Wrapper for an IHostedService, designed for session-based execution.
+/// This wrapper ensures that a hosted service can be started and stopped
+/// based on the lifecycle of a session, providing greater control over
+/// session-specific tasks. It integrates seamlessly with session management
+/// by allowing services to be dynamically tied to a session's lifetime.
+/// </summary>
+public class SessionScopedWrapper(IHostedService hostedService)
+{
+ /// <summary>
+ /// The hosted service that is being wrapped.
+ /// </summary>
+ public readonly IHostedService hostedService = hostedService;
+
+ /// <summary>
+ /// A CancellationTokenSource used to cancel the task.
+ /// </summary>
+ private CancellationTokenSource? cts;
+
+ /// <summary>
+ /// The task that is running for the hosted service.
+ /// </summary>
+ private Task? runningTask;
+
+ /// <summary>
+ /// Starts the hosted service.
+ /// </summary>
+ public void Start()
+ {
+ cts = new CancellationTokenSource();
+ runningTask = hostedService.StartAsync(cts.Token);
+ }
+
+ /// <summary>
+ /// Stops the hosted service and cancels the task.
+ /// </summary>
+ /// <returns>A task representing the asynchronous operation.</returns>
+ public async Task StopAsync()
+ {
+ if (cts != null)
+ {
+ cts.Cancel();
+ try
+ {
+ await hostedService.StopAsync(cts.Token);
+
+ if (runningTask != null)
+ await runningTask;
+ }
+ catch (AggregateException)
+ {
+ // Suppress exceptions caused by task cancellation
+ }
+
+ cts.Dispose();
+ }
+ }
+}
diff --git a/SessionScopedExtension.sln b/SessionScopedExtension.sln
new file mode 100644
index 0000000..3f30c32
--- /dev/null
+++ b/SessionScopedExtension.sln
@@ -0,0 +1,28 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.12.35707.178 d17.12
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SessionScoped", "SessionScoped\SessionScoped.csproj", "{FAE8784B-46C3-4D9F-9187-B671A585A9B6}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SessionScopedTest", "SessionScopedTest\SessionScopedTest.csproj", "{FAD9FE79-92F8-410B-8777-50BEB0A3C670}"
+EndProject
+Global
+ GlobalSection(SolutionConfigurationPlatforms) = preSolution
+ Debug|Any CPU = Debug|Any CPU
+ Release|Any CPU = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(ProjectConfigurationPlatforms) = postSolution
+ {FAE8784B-46C3-4D9F-9187-B671A585A9B6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FAE8784B-46C3-4D9F-9187-B671A585A9B6}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FAE8784B-46C3-4D9F-9187-B671A585A9B6}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FAE8784B-46C3-4D9F-9187-B671A585A9B6}.Release|Any CPU.Build.0 = Release|Any CPU
+ {FAD9FE79-92F8-410B-8777-50BEB0A3C670}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {FAD9FE79-92F8-410B-8777-50BEB0A3C670}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {FAD9FE79-92F8-410B-8777-50BEB0A3C670}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {FAD9FE79-92F8-410B-8777-50BEB0A3C670}.Release|Any CPU.Build.0 = Release|Any CPU
+ EndGlobalSection
+ GlobalSection(SolutionProperties) = preSolution
+ HideSolutionNode = FALSE
+ EndGlobalSection
+EndGlobal
diff --git a/SessionScopedTest/Controllers/ExampleController.cs b/SessionScopedTest/Controllers/ExampleController.cs
new file mode 100644
index 0000000..f9fc2d3
--- /dev/null
+++ b/SessionScopedTest/Controllers/ExampleController.cs
@@ -0,0 +1,16 @@
+using Microsoft.AspNetCore.Mvc;
+using SessionScopedTest.Services;
+
+namespace SessionScopedTest.Controllers;
+
+public class ExampleController(SessionScopedFactory<ExampleService> factory) : ControllerBase
+{
+ public async Task<IActionResult> Index()
+ {
+ HttpContext.Session.SetString("Started", DateTime.Now.ToString());
+
+ var result = await factory.Instance.CalcAsync(123, 456);
+
+ return Ok(result);
+ }
+}
diff --git a/SessionScopedTest/Program.cs b/SessionScopedTest/Program.cs
new file mode 100644
index 0000000..0cdb5cc
--- /dev/null
+++ b/SessionScopedTest/Program.cs
@@ -0,0 +1,35 @@
+
+using SessionScopedTest.Services;
+
+var builder = WebApplication.CreateBuilder(new WebApplicationOptions
+{
+ ContentRootPath = AppContext.BaseDirectory
+});
+
+var services = builder.Services;
+
+services.AddHttpContextAccessor();
+
+services.AddMvc();
+
+services.AddDistributedMemoryCache();
+services.AddSession(options =>
+{
+ options.IdleTimeout = TimeSpan.FromSeconds(10); // testing timeout
+ options.Cookie.Name = ".AspNetCore.Session";
+ options.Cookie.HttpOnly = true;
+ options.Cookie.IsEssential = true;
+});
+
+services.AddSessionScoped<ExampleService>();
+
+
+var app = builder.Build();
+
+app.UseDefaultFiles();
+app.UseStaticFiles();
+app.UseSession();
+app.MapDefaultControllerRoute();
+
+app.Run();
+
diff --git a/SessionScopedTest/Properties/launchSettings.json b/SessionScopedTest/Properties/launchSettings.json
new file mode 100644
index 0000000..cde5974
--- /dev/null
+++ b/SessionScopedTest/Properties/launchSettings.json
@@ -0,0 +1,23 @@
+{
+ "$schema": "https://json.schemastore.org/launchsettings.json",
+ "profiles": {
+ "http": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "http://localhost:5162",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ },
+ "https": {
+ "commandName": "Project",
+ "dotnetRunMessages": true,
+ "launchBrowser": true,
+ "applicationUrl": "https://localhost:7065;http://localhost:5162",
+ "environmentVariables": {
+ "ASPNETCORE_ENVIRONMENT": "Development"
+ }
+ }
+ }
+}
diff --git a/SessionScopedTest/Services/ExampleService.cs b/SessionScopedTest/Services/ExampleService.cs
new file mode 100644
index 0000000..6842f55
--- /dev/null
+++ b/SessionScopedTest/Services/ExampleService.cs
@@ -0,0 +1,29 @@
+namespace SessionScopedTest.Services;
+
+public class ExampleService(ILoggerFactory loggerFactory) : IHostedService
+{
+ private readonly ILogger Logger = loggerFactory.CreateLogger<ExampleService>();
+
+ public async Task<int> CalcAsync(int a, int b)
+ {
+ Logger.LogInformation("CalcAsync {a} x {b}", a , b);
+
+ await Task.Delay(100);
+
+ return a * b;
+ }
+
+ public async Task StartAsync(CancellationToken cancellationToken)
+ {
+ Logger.LogWarning("StartAsync");
+
+ await Task.Delay(100);
+ }
+
+ public async Task StopAsync(CancellationToken cancellationToken)
+ {
+ Logger.LogWarning("StopAsync");
+
+ await Task.Delay(100);
+ }
+}
diff --git a/SessionScopedTest/SessionScopedTest.csproj b/SessionScopedTest/SessionScopedTest.csproj
new file mode 100644
index 0000000..745075e
--- /dev/null
+++ b/SessionScopedTest/SessionScopedTest.csproj
@@ -0,0 +1,13 @@
+<Project Sdk="Microsoft.NET.Sdk.Web">
+
+ <PropertyGroup>
+ <TargetFramework>net9.0</TargetFramework>
+ <Nullable>enable</Nullable>
+ <ImplicitUsings>enable</ImplicitUsings>
+ </PropertyGroup>
+
+ <ItemGroup>
+ <ProjectReference Include="..\SessionScoped\SessionScoped.csproj" />
+ </ItemGroup>
+
+</Project>
diff --git a/SessionScopedTest/appsettings.Development.json b/SessionScopedTest/appsettings.Development.json
new file mode 100644
index 0000000..0c208ae
--- /dev/null
+++ b/SessionScopedTest/appsettings.Development.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information",
+ "Microsoft.AspNetCore": "Warning"
+ }
+ }
+}
diff --git a/SessionScopedTest/appsettings.json b/SessionScopedTest/appsettings.json
new file mode 100644
index 0000000..327ef15
--- /dev/null
+++ b/SessionScopedTest/appsettings.json
@@ -0,0 +1,8 @@
+{
+ "Logging": {
+ "LogLevel": {
+ "Default": "Information"
+ }
+ },
+ "AllowedHosts": "*"
+}
diff --git a/SessionScopedTest/wwwroot/Index.html b/SessionScopedTest/wwwroot/Index.html
new file mode 100644
index 0000000..6f9d7a2
--- /dev/null
+++ b/SessionScopedTest/wwwroot/Index.html
@@ -0,0 +1,10 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8" />
+ <title></title>
+</head>
+<body>
+ <a href="./Example">calculate 123 x 456</a>
+</body>
+</html>
\ No newline at end of file