From 33d1b6e07afc9332821f89ffdf370a83ab64f45b Mon Sep 17 00:00:00 2001 From: MDA2AV Date: Sat, 23 May 2026 21:13:38 +0000 Subject: [PATCH] tflow --- frameworks/aspnet-minimal-twinflow/AppData.cs | 70 ++++ frameworks/aspnet-minimal-twinflow/Dockerfile | 22 ++ .../aspnet-minimal-twinflow/Handlers.cs | 319 ++++++++++++++++++ frameworks/aspnet-minimal-twinflow/Models.cs | 64 ++++ .../Pages/Fortunes.cshtml | 16 + .../Pages/Fortunes.cshtml.cs | 31 ++ frameworks/aspnet-minimal-twinflow/Program.cs | 101 ++++++ frameworks/aspnet-minimal-twinflow/README.md | 40 +++ .../aspnet-minimal.csproj | 13 + frameworks/aspnet-minimal-twinflow/build.sh | 5 + frameworks/aspnet-minimal-twinflow/meta.json | 29 ++ .../aspnet-websocket-twinflow/Dockerfile | 10 + .../aspnet-websocket-twinflow/Program.cs | 54 +++ .../aspnet-websocket.csproj | 9 + .../aspnet-websocket-twinflow/meta.json | 14 + 15 files changed, 797 insertions(+) create mode 100644 frameworks/aspnet-minimal-twinflow/AppData.cs create mode 100644 frameworks/aspnet-minimal-twinflow/Dockerfile create mode 100644 frameworks/aspnet-minimal-twinflow/Handlers.cs create mode 100644 frameworks/aspnet-minimal-twinflow/Models.cs create mode 100644 frameworks/aspnet-minimal-twinflow/Pages/Fortunes.cshtml create mode 100644 frameworks/aspnet-minimal-twinflow/Pages/Fortunes.cshtml.cs create mode 100644 frameworks/aspnet-minimal-twinflow/Program.cs create mode 100644 frameworks/aspnet-minimal-twinflow/README.md create mode 100644 frameworks/aspnet-minimal-twinflow/aspnet-minimal.csproj create mode 100755 frameworks/aspnet-minimal-twinflow/build.sh create mode 100644 frameworks/aspnet-minimal-twinflow/meta.json create mode 100644 frameworks/aspnet-websocket-twinflow/Dockerfile create mode 100644 frameworks/aspnet-websocket-twinflow/Program.cs create mode 100644 frameworks/aspnet-websocket-twinflow/aspnet-websocket.csproj create mode 100644 frameworks/aspnet-websocket-twinflow/meta.json diff --git a/frameworks/aspnet-minimal-twinflow/AppData.cs b/frameworks/aspnet-minimal-twinflow/AppData.cs new file mode 100644 index 000000000..40db06841 --- /dev/null +++ b/frameworks/aspnet-minimal-twinflow/AppData.cs @@ -0,0 +1,70 @@ +using System.Text.Json; +using Npgsql; +using StackExchange.Redis; + +static class AppData +{ + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; + + public static List? DatasetItems; + + public static NpgsqlDataSource? PgDataSource; + + // Optional Redis cache for the crud profile. When REDIS_URL is set, + // crud handlers use Redis as a shared cache; otherwise they use the + // in-process IMemoryCache. Mirrors hono-bun's pattern so frameworks + // can be compared apples-to-apples on the same cache topology. + public static IDatabase? RedisDb; + + public static void Load() + { + LoadDataset(); + OpenPgPool(); + OpenRedis(); + } + + static void LoadDataset() + { + var path = Environment.GetEnvironmentVariable("DATASET_PATH") ?? "/data/dataset.json"; + if (!File.Exists(path)) return; + DatasetItems = JsonSerializer.Deserialize>(File.ReadAllText(path), JsonOptions); + } + + static void OpenPgPool() + { + var dbUrl = Environment.GetEnvironmentVariable("DATABASE_URL"); + if (string.IsNullOrEmpty(dbUrl)) return; + try + { + var uri = new Uri(dbUrl); + var userInfo = uri.UserInfo.Split(':'); + var maxConn = int.TryParse(Environment.GetEnvironmentVariable("DATABASE_MAX_CONN"), out var p) && p > 0 ? p : 256; + var minConn = Math.Min(64, maxConn); + var connStr = $"Host={uri.Host};Port={uri.Port};Username={userInfo[0]};Password={userInfo[1]};Database={uri.AbsolutePath.TrimStart('/')};Maximum Pool Size={maxConn};Minimum Pool Size={minConn};Multiplexing=true;No Reset On Close=true;Max Auto Prepare=20;Auto Prepare Min Usages=1"; + var builder = new NpgsqlDataSourceBuilder(connStr); + PgDataSource = builder.Build(); + } + catch { } + } + + static void OpenRedis() + { + var redisUrl = Environment.GetEnvironmentVariable("REDIS_URL"); + if (string.IsNullOrEmpty(redisUrl)) return; + try + { + // REDIS_URL is "redis://host:port" — convert to StackExchange's + // "host:port" configuration string. + var uri = new Uri(redisUrl); + var config = ConfigurationOptions.Parse($"{uri.Host}:{uri.Port}"); + config.AbortOnConnectFail = false; + var muxer = ConnectionMultiplexer.Connect(config); + RedisDb = muxer.GetDatabase(); + } + catch { } + } +} diff --git a/frameworks/aspnet-minimal-twinflow/Dockerfile b/frameworks/aspnet-minimal-twinflow/Dockerfile new file mode 100644 index 000000000..c1f255b7d --- /dev/null +++ b/frameworks/aspnet-minimal-twinflow/Dockerfile @@ -0,0 +1,22 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /app +COPY frameworks/aspnet-minimal-twinflow/ . +COPY data/static/ wwwroot/static/ +RUN dotnet publish -c Release -o out + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 + + +ADD https://packages.microsoft.com/config/debian/12/packages-microsoft-prod.deb /packages-microsoft-prod.deb + +RUN dpkg -i packages-microsoft-prod.deb && rm packages-microsoft-prod.deb \ + && apt-get update \ + && apt-get install -y --no-install-recommends libmsquic \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app +COPY --from=build /app/out . +EXPOSE 8080 8081 8082 8443/tcp 8443/udp + +ENTRYPOINT ["dotnet", "aspnet-minimal.dll"] diff --git a/frameworks/aspnet-minimal-twinflow/Handlers.cs b/frameworks/aspnet-minimal-twinflow/Handlers.cs new file mode 100644 index 000000000..856c6e638 --- /dev/null +++ b/frameworks/aspnet-minimal-twinflow/Handlers.cs @@ -0,0 +1,319 @@ +using System.Text.Json; +using System.Buffers; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http.HttpResults; +using Microsoft.Extensions.Caching.Memory; +using StackExchange.Redis; + + +[JsonSerializable(typeof(ResponseDto))] +[JsonSerializable(typeof(ResponseDto))] +[JsonSerializable(typeof(DbResponseItemDto))] +[JsonSerializable(typeof(ProcessedItem))] +[JsonSerializable(typeof(RatingInfo))] +[JsonSerializable(typeof(List))] +[JsonSerializable(typeof(CrudListResponse))] +[JsonSerializable(typeof(CrudWriteResponse))] +[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] +partial class AppJsonContext : JsonSerializerContext { } + +static class Handlers +{ + // Returning `string` makes ASP.NET minimal APIs set Content-Type to + // text/plain automatically. Returning `int` defaults to JSON and + // serializes the bare number — which violates the baseline contract. + public static string Sum(int a, int b) => (a + b).ToString(); + + public static async ValueTask SumBody(int a, int b, HttpRequest req) + { + using var reader = new StreamReader(req.Body); + return (a + b + int.Parse(await reader.ReadToEndAsync())).ToString(); + } + + public static string Text() => "ok"; + + public static async ValueTask Upload(HttpRequest req) + { + long size = 0; + var buffer = ArrayPool.Shared.Rent(65536); + try + { + int read; + while ((read = await req.Body.ReadAsync(buffer.AsMemory(0, buffer.Length))) > 0) + { + size += read; + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + + return size.ToString(); + } + + public static Results>, ProblemHttpResult> Json(int count, HttpRequest req) + { + var source = AppData.DatasetItems; + if (source == null) + return TypedResults.Problem("Dataset not loaded"); + + if (count > source.Count) count = source.Count; + if (count < 0) count = 0; + + int m = 1; + if (req.Query.TryGetValue("m", out var mVal) && int.TryParse(mVal, out var pm)) m = pm; + + var items = new ProcessedItem[count]; + + for (int i = 0; i < count; i++) + { + var item = source[i]; + items[i] = new ProcessedItem + { + Id = item.Id, + Name = item.Name, + Category = item.Category, + Price = item.Price, + Quantity = item.Quantity, + Active = item.Active, + Tags = item.Tags, + Rating = item.Rating, + Total = item.Price * item.Quantity * m + }; + } + + return TypedResults.Json(new ResponseDto(items, count), AppJsonContext.Default.ResponseDtoProcessedItem); + } + + public static async Task>, ProblemHttpResult>> AsyncDatabase(HttpRequest req) + { + if (AppData.PgDataSource == null) + return TypedResults.Problem("DB not available"); + + // Query Parsing + double min = 10, max = 50; + int limit = 50; + var query = req.Query; + if (query.TryGetValue("min", out var minVal) && double.TryParse(minVal, out var pmin)) min = pmin; + if (query.TryGetValue("max", out var maxVal) && double.TryParse(maxVal, out var pmax)) max = pmax; + if (query.TryGetValue("limit", out var limVal) && int.TryParse(limVal, out var plim)) limit = Math.Clamp(plim, 1, 50); + + await using var cmd = AppData.PgDataSource.CreateCommand( + "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN $1 AND $2 LIMIT $3"); + + cmd.Parameters.AddWithValue(min); + cmd.Parameters.AddWithValue(max); + cmd.Parameters.AddWithValue(limit); + + await using var reader = await cmd.ExecuteReaderAsync(); + + var items = new List(limit); + + while (await reader.ReadAsync()) + { + items.Add(new DbResponseItemDto + { + Id = reader.GetInt32(0), + Name = reader.GetString(1), + Category = reader.GetString(2), + Price = (int)reader.GetDouble(3), + Quantity = reader.GetInt32(4), + Active = reader.GetBoolean(5), + Tags = JsonSerializer.Deserialize(reader.GetString(6), AppJsonContext.Default.ListString)!, + Rating = new RatingInfo { Score = (int)reader.GetDouble(7), Count = reader.GetInt32(8) } + }); + } + + return TypedResults.Json(new ResponseDto(items, items.Count), AppJsonContext.Default.ResponseDtoDbResponseItemDto); + } + + // ── CRUD handlers ────────────────────────────────────────────────── + // + // Realistic REST API with paginated list, cached single-item read, + // create, and update. Cache-aside on single-item reads with 200ms TTL, + // invalidated on PUT. List queries always hit Postgres. + + private static readonly MemoryCacheEntryOptions _crudCacheOpts = + new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMilliseconds(200) }; + + private static readonly JsonSerializerOptions _crudJsonOpts = + new(JsonSerializerDefaults.Web); + + // GET /crud/items?category=X&page=N&limit=M — paginated list (always DB, never cached) + public static async Task CrudList(HttpRequest req) + { + if (AppData.PgDataSource is null) + return TypedResults.Problem("DB not available"); + + var query = req.Query; + var category = query["category"].ToString(); + if (string.IsNullOrEmpty(category)) category = "electronics"; + int.TryParse(query["page"], out var page); + if (page < 1) page = 1; + int.TryParse(query["limit"], out var limit); + if (limit < 1 || limit > 50) limit = 10; + var offset = (page - 1) * limit; + + // Single data query. The previous COUNT(*) pass was 90%+ of PG CPU + // because concurrent writes kept the visibility map dirty, forcing + // heap fetches on every index-only scan. "Load more" pagination + // (return page size, no total) is a realistic alternative that + // removes that dominant cost. + await using var cmd = AppData.PgDataSource.CreateCommand( + "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count " + + "FROM items WHERE category = $1 ORDER BY id LIMIT $2 OFFSET $3"); + cmd.Parameters.AddWithValue(category); + cmd.Parameters.AddWithValue(limit); + cmd.Parameters.AddWithValue(offset); + + await using var reader = await cmd.ExecuteReaderAsync(); + var items = new List(); + while (await reader.ReadAsync()) + { + items.Add(new DbResponseItemDto + { + Id = reader.GetInt32(0), + Name = reader.GetString(1), + Category = reader.GetString(2), + Price = reader.GetInt32(3), + Quantity = reader.GetInt32(4), + Active = reader.GetBoolean(5), + Tags = JsonSerializer.Deserialize(reader.GetString(6), AppJsonContext.Default.ListString)!, + Rating = new RatingInfo { Score = (int)reader.GetDouble(7), Count = reader.GetInt32(8) } + }); + } + + return TypedResults.Json(new CrudListResponse { Items = items, Total = items.Count, Page = page, Limit = limit }, + AppJsonContext.Default.CrudListResponse); + } + + // GET /crud/items/{id} — single item, cached with 200ms TTL. + // Redis when REDIS_URL is set (cache stores pre-serialized JSON string so + // HIT path skips a Serialize+Deserialize round trip); else in-process + // IMemoryCache (caches the typed DTO). + public static async Task CrudRead(int id, IMemoryCache cache, HttpContext ctx) + { + if (AppData.PgDataSource is null) + return TypedResults.Problem("DB not available"); + + var cacheKey = $"crud:{id}"; + + if (AppData.RedisDb is not null) + { + var cachedJson = await AppData.RedisDb.StringGetAsync(cacheKey); + if (cachedJson.HasValue) + { + ctx.Response.Headers["X-Cache"] = "HIT"; + return Results.Content((string)cachedJson!, "application/json"); + } + + var item = await FetchItemByIdAsync(id); + if (item is null) return TypedResults.NotFound(); + + var json = JsonSerializer.Serialize(item, AppJsonContext.Default.DbResponseItemDto); + await AppData.RedisDb.StringSetAsync(cacheKey, json, TimeSpan.FromMilliseconds(200)); + ctx.Response.Headers["X-Cache"] = "MISS"; + return Results.Content(json, "application/json"); + } + + if (cache.TryGetValue(cacheKey, out DbResponseItemDto? cached)) + { + ctx.Response.Headers["X-Cache"] = "HIT"; + return TypedResults.Json(cached, AppJsonContext.Default.DbResponseItemDto); + } + + var dto = await FetchItemByIdAsync(id); + if (dto is null) return TypedResults.NotFound(); + + cache.Set(cacheKey, dto, _crudCacheOpts); + ctx.Response.Headers["X-Cache"] = "MISS"; + return TypedResults.Json(dto, AppJsonContext.Default.DbResponseItemDto); + } + + private static async Task FetchItemByIdAsync(int id) + { + await using var cmd = AppData.PgDataSource!.CreateCommand( + "SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count " + + "FROM items WHERE id = $1 LIMIT 1"); + cmd.Parameters.AddWithValue(id); + + await using var reader = await cmd.ExecuteReaderAsync(); + if (!await reader.ReadAsync()) return null; + + return new DbResponseItemDto + { + Id = reader.GetInt32(0), + Name = reader.GetString(1), + Category = reader.GetString(2), + Price = reader.GetInt32(3), + Quantity = reader.GetInt32(4), + Active = reader.GetBoolean(5), + Tags = JsonSerializer.Deserialize(reader.GetString(6), AppJsonContext.Default.ListString)!, + Rating = new RatingInfo { Score = (int)reader.GetDouble(7), Count = reader.GetInt32(8) } + }; + } + + // POST /crud/items — create item, return 201 + public static async Task CrudCreate(HttpRequest req) + { + if (AppData.PgDataSource is null) + return TypedResults.Problem("DB not available"); + + using var sr = new StreamReader(req.Body); + var body = await sr.ReadToEndAsync(); + var input = JsonSerializer.Deserialize(body, _crudJsonOpts); + if (input is null) + return TypedResults.BadRequest(); + + await using var cmd = AppData.PgDataSource.CreateCommand( + "INSERT INTO items (id, name, category, price, quantity, active, tags, rating_score, rating_count) " + + "VALUES ($1, $2, $3, $4, $5, true, '[\"bench\"]', 0, 0) " + + "ON CONFLICT (id) DO UPDATE SET name = $2, price = $4, quantity = $5 " + + "RETURNING id"); + cmd.Parameters.AddWithValue(input.Id); + cmd.Parameters.AddWithValue(input.Name ?? "New Product"); + cmd.Parameters.AddWithValue(input.Category ?? "test"); + cmd.Parameters.AddWithValue(input.Price); + cmd.Parameters.AddWithValue(input.Quantity); + + var newId = (int)(await cmd.ExecuteScalarAsync())!; + return TypedResults.Json( + new CrudWriteResponse { Id = newId, Name = input.Name, Category = input.Category, Price = input.Price, Quantity = input.Quantity }, + AppJsonContext.Default.CrudWriteResponse, statusCode: 201); + } + + // PUT /crud/items/{id} — update item, invalidate cache + public static async Task CrudUpdate(int id, HttpRequest req, IMemoryCache cache) + { + if (AppData.PgDataSource is null) + return TypedResults.Problem("DB not available"); + + using var sr = new StreamReader(req.Body); + var body = await sr.ReadToEndAsync(); + var input = JsonSerializer.Deserialize(body, _crudJsonOpts); + if (input is null) + return TypedResults.BadRequest(); + + await using var cmd = AppData.PgDataSource.CreateCommand( + "UPDATE items SET name = $1, price = $2, quantity = $3 WHERE id = $4"); + cmd.Parameters.AddWithValue(input.Name ?? "Updated"); + cmd.Parameters.AddWithValue(input.Price); + cmd.Parameters.AddWithValue(input.Quantity); + cmd.Parameters.AddWithValue(id); + + var affected = await cmd.ExecuteNonQueryAsync(); + if (affected == 0) return TypedResults.NotFound(); + + var cacheKey = $"crud:{id}"; + if (AppData.RedisDb is not null) + await AppData.RedisDb.KeyDeleteAsync(cacheKey); + else + cache.Remove(cacheKey); + return TypedResults.Json( + new CrudWriteResponse { Id = id, Name = input.Name, Price = input.Price, Quantity = input.Quantity }, + AppJsonContext.Default.CrudWriteResponse); + } +} + +record CrudItemInput(int Id, string? Name, string? Category, int Price, int Quantity); \ No newline at end of file diff --git a/frameworks/aspnet-minimal-twinflow/Models.cs b/frameworks/aspnet-minimal-twinflow/Models.cs new file mode 100644 index 000000000..93e04f364 --- /dev/null +++ b/frameworks/aspnet-minimal-twinflow/Models.cs @@ -0,0 +1,64 @@ +sealed record ResponseDto(IReadOnlyList Items, int Count); + + +sealed class DbResponseItemDto +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public int Price { get; set; } + public int Quantity { get; set; } + public bool Active { get; set; } + public List Tags { get; set; } = []; + public RatingInfo Rating { get; set; } = new(); +} + +sealed class DatasetItem +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public int Price { get; set; } + public int Quantity { get; set; } + public bool Active { get; set; } + public List Tags { get; set; } = []; + public RatingInfo Rating { get; set; } = new(); +} + +sealed class ProcessedItem +{ + public int Id { get; set; } + public string Name { get; set; } = ""; + public string Category { get; set; } = ""; + public int Price { get; set; } + public int Quantity { get; set; } + public bool Active { get; set; } + public List Tags { get; set; } = []; + public RatingInfo Rating { get; set; } = new(); + public long Total { get; set; } +} + +sealed class RatingInfo +{ + public int Score { get; set; } + public int Count { get; set; } +} + +sealed class CrudListResponse +{ + public List Items { get; set; } = []; + public long Total { get; set; } + public int Page { get; set; } + public int Limit { get; set; } +} + +sealed class CrudWriteResponse +{ + public int Id { get; set; } + public string? Name { get; set; } + public string? Category { get; set; } + public int Price { get; set; } + public int Quantity { get; set; } +} + +public sealed record Fortune(int Id, string Message); diff --git a/frameworks/aspnet-minimal-twinflow/Pages/Fortunes.cshtml b/frameworks/aspnet-minimal-twinflow/Pages/Fortunes.cshtml new file mode 100644 index 000000000..880992619 --- /dev/null +++ b/frameworks/aspnet-minimal-twinflow/Pages/Fortunes.cshtml @@ -0,0 +1,16 @@ +@page "/fortunes" +@model FortunesModel +@{ Layout = null; } + + +Fortunes + + + +@foreach (var f in Model.Fortunes) +{ + +} +
idmessage
@f.Id@f.Message
+ + diff --git a/frameworks/aspnet-minimal-twinflow/Pages/Fortunes.cshtml.cs b/frameworks/aspnet-minimal-twinflow/Pages/Fortunes.cshtml.cs new file mode 100644 index 000000000..16bd4fc67 --- /dev/null +++ b/frameworks/aspnet-minimal-twinflow/Pages/Fortunes.cshtml.cs @@ -0,0 +1,31 @@ +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; + +public sealed class FortunesModel : PageModel +{ + public List Fortunes { get; private set; } = []; + + public async Task OnGetAsync() + { + if (AppData.PgDataSource is null) + return new StatusCodeResult(500); + + var list = new List(201); + await using (var cmd = AppData.PgDataSource.CreateCommand("SELECT id, message FROM fortune")) + await using (var reader = await cmd.ExecuteReaderAsync()) + { + while (await reader.ReadAsync()) + { + list.Add(new Fortune(reader.GetInt32(0), reader.GetString(1))); + } + } + + // Runtime-injected row defeats whole-page memoization: the rendered + // HTML must vary per request, even though the seeded rows don't. + list.Add(new Fortune(0, "Additional fortune added at request time.")); + list.Sort(static (a, b) => string.CompareOrdinal(a.Message, b.Message)); + + Fortunes = list; + return Page(); + } +} diff --git a/frameworks/aspnet-minimal-twinflow/Program.cs b/frameworks/aspnet-minimal-twinflow/Program.cs new file mode 100644 index 000000000..b00a2c207 --- /dev/null +++ b/frameworks/aspnet-minimal-twinflow/Program.cs @@ -0,0 +1,101 @@ +using System.Security.Cryptography.X509Certificates; + +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.StaticFiles; +using Microsoft.Extensions.Caching.Memory; +using Twinflow; + +var builder = WebApplication.CreateBuilder(args); +builder.Logging.SetMinimumLevel(LogLevel.Critical); +builder.Logging.ClearProviders(); + +builder.WebHost.UseTwinflow(o => o.ReactorCount = 8); + +builder.Services.AddMemoryCache(); +builder.Services.AddRazorPages(); + +var certPath = Environment.GetEnvironmentVariable("TLS_CERT") ?? "/certs/server.crt"; +var keyPath = Environment.GetEnvironmentVariable("TLS_KEY") ?? "/certs/server.key"; +var hasCert = File.Exists(certPath) && File.Exists(keyPath); + +builder.WebHost.ConfigureKestrel(options => +{ + options.Limits.Http2.MaxStreamsPerConnection = 256; + options.Limits.Http2.InitialConnectionWindowSize = 2 * 1024 * 1024; + options.Limits.Http2.InitialStreamWindowSize = 1024 * 1024; + + options.ListenAnyIP(8080, lo => + { + lo.Protocols = HttpProtocols.Http1; + }); + + // h2c prior-knowledge listener for the baseline-h2c / json-h2c profiles. + // Protocols = Http2 with no UseHttps() gives Kestrel cleartext HTTP/2 + // from the first byte. Clients that try HTTP/1.1 on this port get + // rejected, which is what validate.sh's h2c anti-cheat requires. + options.ListenAnyIP(8082, lo => + { + lo.Protocols = HttpProtocols.Http2; + }); + + if (hasCert) + { + options.ListenAnyIP(8443, lo => + { + lo.Protocols = HttpProtocols.Http1AndHttp2AndHttp3; + lo.UseHttps(X509Certificate2.CreateFromPemFile(certPath, keyPath)); + }); + + // HTTP/1.1-only TLS listener for the json-tls profile. Kestrel + // advertises http/1.1 via ALPN so HTTP/1.1-only clients (wrk) negotiate + // correctly and never upgrade to h2. + options.ListenAnyIP(8081, lo => + { + lo.Protocols = HttpProtocols.Http1; + lo.UseHttps(X509Certificate2.CreateFromPemFile(certPath, keyPath)); + }); + } +}); + +builder.Services.AddResponseCompression(); + +var app = builder.Build(); + +app.UseResponseCompression(); + +app.Use((ctx, next) => +{ + ctx.Response.Headers.Server = "aspnet-minimal"; + return next(); +}); + +AppData.Load(); + +app.MapGet("/pipeline", Handlers.Text); + +app.MapGet("/baseline11", Handlers.Sum); +app.MapPost("/baseline11", Handlers.SumBody); +app.MapGet("/baseline2", Handlers.Sum); + +app.MapPost("/upload", Handlers.Upload); +app.MapGet("/json/{count}", Handlers.Json); +app.MapGet("/async-db", Handlers.AsyncDatabase); + +// ── CRUD endpoints ───────────────────────────────────────────────────────── +// Realistic REST API: paginated list, cached single-item read, create, update. +// In-process IMemoryCache with 1s TTL on single-item reads, invalidated on PUT. + +app.MapGet("/crud/items", Handlers.CrudList); +app.MapGet("/crud/items/{id:int}", Handlers.CrudRead); +app.MapPost("/crud/items", Handlers.CrudCreate); +app.MapPut("/crud/items/{id:int}", Handlers.CrudUpdate); + +// /fortunes is served by the Razor page at Pages/Fortunes.cshtml +// (route "/fortunes" declared via the page's @page directive). MapRazorPages +// wires up the MVC/Razor pipeline so the page model can render Razor markup +// — the standard ASP.NET production path for HTML responses. +app.MapRazorPages(); + +app.MapStaticAssets(); + +app.Run(); diff --git a/frameworks/aspnet-minimal-twinflow/README.md b/frameworks/aspnet-minimal-twinflow/README.md new file mode 100644 index 000000000..0992f09eb --- /dev/null +++ b/frameworks/aspnet-minimal-twinflow/README.md @@ -0,0 +1,40 @@ +# aspnet-minimal + +Minimal ASP.NET Core HTTP server using .NET 10 with Kestrel and minimal API routing. + +## Stack + +- **Language:** C# / .NET 10 +- **Framework:** ASP.NET Core Minimal APIs +- **Engine:** Kestrel +- **Build:** Framework-dependent publish, `mcr.microsoft.com/dotnet/aspnet:10.0` runtime (Debian 12) with `libmsquic` installed for HTTP/3 + +## Endpoints + +| Endpoint | Method | Description | +|---|---|---| +| `/pipeline` | GET | Returns `ok` (plain text) | +| `/baseline11` | GET | Sums query parameter values | +| `/baseline11` | POST | Sums query parameters + request body | +| `/baseline2` | GET | Sums query parameter values (HTTP/2 variant) | +| `/json/{count}` | GET | Returns `count` items from the preloaded dataset; honors `Accept-Encoding: gzip/br/deflate` for the `json-comp` profile | +| `/async-db` | GET | Postgres range query: `SELECT ... WHERE price BETWEEN $min AND $max LIMIT $limit` | +| `/upload` | POST | Streams the request body and returns the byte count | +| `/crud/items` | GET | Paginated list by category with two queries (data + count) | +| `/crud/items/{id}` | GET | Single item read with `IMemoryCache` (1s TTL), returns `X-Cache: HIT/MISS` | +| `/crud/items` | POST | Create item via INSERT with ON CONFLICT upsert, returns 201 | +| `/crud/items/{id}` | PUT | Update item and invalidate cache entry | +| `/static/*` | GET | Serves files from `/data/static` via `MapStaticAssets` with precomputed ETags + compression | + +## Notes + +- HTTP/1.1 on port 8080, HTTP/1+2+3 on port 8443 (TCP **and** UDP for QUIC), h1+TLS on port 8081 (`json-tls` profile) +- HTTP/3 via MsQuic (`libmsquic` installed in the runtime image); Kestrel advertises h3 through the default Alt-Svc header so clients upgrade from h2 +- TLS certs loaded from `$TLS_CERT` / `$TLS_KEY` (default `/certs/server.crt` + `/certs/server.key`) +- Logging disabled (`ClearProviders()`) for throughput; `Server: aspnet-minimal` header set via a lightweight middleware +- `AddResponseCompression()` + `UseResponseCompression()` drives `/json/{count}` gzip encoding for the `json-comp` profile +- HTTP/2 tuned: 256 max streams per connection, 2 MB initial connection window, 1 MB stream window +- `/upload` reads the request body into a 64 KB pooled buffer (`ArrayPool.Shared`) and returns the byte count — no full-body allocation +- JSON responses use source-generated `JsonSerializerContext` (`AppJsonContext`) so the hot path avoids reflection +- Postgres pooled via `Npgsql.NpgsqlDataSource` built once at startup from `DATABASE_URL` +- Source split: `Program.cs` (startup + Kestrel), `Handlers.cs` (routes + JSON ctx), `AppData.cs` (dataset + pg pool), `Models.cs` (DTOs) diff --git a/frameworks/aspnet-minimal-twinflow/aspnet-minimal.csproj b/frameworks/aspnet-minimal-twinflow/aspnet-minimal.csproj new file mode 100644 index 000000000..c045b127d --- /dev/null +++ b/frameworks/aspnet-minimal-twinflow/aspnet-minimal.csproj @@ -0,0 +1,13 @@ + + + net10.0 + enable + enable + true + + + + + + + diff --git a/frameworks/aspnet-minimal-twinflow/build.sh b/frameworks/aspnet-minimal-twinflow/build.sh new file mode 100755 index 000000000..3a0271a80 --- /dev/null +++ b/frameworks/aspnet-minimal-twinflow/build.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" +docker build -t httparena-aspnet-minimal-twinflow -f "$SCRIPT_DIR/Dockerfile" "$ROOT_DIR" diff --git a/frameworks/aspnet-minimal-twinflow/meta.json b/frameworks/aspnet-minimal-twinflow/meta.json new file mode 100644 index 000000000..20daf8437 --- /dev/null +++ b/frameworks/aspnet-minimal-twinflow/meta.json @@ -0,0 +1,29 @@ +{ + "display_name": "aspnet-minimal-twinflow", + "language": "C#", + "type": "tuned", + "engine": "kestrel", + "description": "Minimal ASP.NET Core server using .NET 10 with Kestrel + Twinflow and minimal API routing.", + "repo": "https://github.com/dotnet/aspnetcore", + "enabled": true, + "tests": [ + "baseline", + "pipelined", + "limited-conn", + "json", + "json-comp", + "json-tls", + "upload", + "api-4", + "api-16", + "static", + "async-db", + "crud", + "fortunes", + "baseline-h2", + "static-h2", + "baseline-h2c", + "json-h2c" + ], + "maintainers": ["MDA2AV"] +} \ No newline at end of file diff --git a/frameworks/aspnet-websocket-twinflow/Dockerfile b/frameworks/aspnet-websocket-twinflow/Dockerfile new file mode 100644 index 000000000..a06c6ee42 --- /dev/null +++ b/frameworks/aspnet-websocket-twinflow/Dockerfile @@ -0,0 +1,10 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /app +COPY . . +RUN dotnet publish -c Release -o out + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 +WORKDIR /app +COPY --from=build /app/out . +EXPOSE 8080 +ENTRYPOINT ["dotnet", "aspnet-websocket.dll"] diff --git a/frameworks/aspnet-websocket-twinflow/Program.cs b/frameworks/aspnet-websocket-twinflow/Program.cs new file mode 100644 index 000000000..5f8934c46 --- /dev/null +++ b/frameworks/aspnet-websocket-twinflow/Program.cs @@ -0,0 +1,54 @@ +using System.Net.WebSockets; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Twinflow; + +var builder = WebApplication.CreateBuilder(args); +builder.Logging.ClearProviders(); +builder.WebHost.UseTwinflow(o => o.ReactorCount = 16); +builder.WebHost.ConfigureKestrel(options => +{ + options.ListenAnyIP(8080, lo => + { + lo.Protocols = HttpProtocols.Http1AndHttp2; + }); +}); + +var app = builder.Build(); + +app.UseWebSockets(); + +app.Map("/ws", async context => +{ + if (!context.WebSockets.IsWebSocketRequest) + { + context.Response.StatusCode = 400; + await context.Response.WriteAsync("Not a WebSocket request"); + return; + } + + using var ws = await context.WebSockets.AcceptWebSocketAsync(); + var buffer = new byte[4096]; + + while (true) + { + var result = await ws.ReceiveAsync(buffer, CancellationToken.None); + + if (result.MessageType == WebSocketMessageType.Close) + { + await ws.CloseAsync( + WebSocketCloseStatus.NormalClosure, + null, + CancellationToken.None); + break; + } + + await ws.SendAsync( + new ArraySegment(buffer, 0, result.Count), + result.MessageType, + result.EndOfMessage, + CancellationToken.None); + } +}); + +Console.WriteLine("Application started."); +app.Run(); diff --git a/frameworks/aspnet-websocket-twinflow/aspnet-websocket.csproj b/frameworks/aspnet-websocket-twinflow/aspnet-websocket.csproj new file mode 100644 index 000000000..f1d19050b --- /dev/null +++ b/frameworks/aspnet-websocket-twinflow/aspnet-websocket.csproj @@ -0,0 +1,9 @@ + + + net10.0 + enable + + + + + diff --git a/frameworks/aspnet-websocket-twinflow/meta.json b/frameworks/aspnet-websocket-twinflow/meta.json new file mode 100644 index 000000000..60b43119c --- /dev/null +++ b/frameworks/aspnet-websocket-twinflow/meta.json @@ -0,0 +1,14 @@ +{ + "display_name": "aspnet-websocket-twinflow", + "language": "C#", + "type": "production", + "engine": "kestrel", + "description": "ASP.NET Core WebSocket echo server using .NET 10 preview with Kestrel and minimal API.", + "repo": "https://github.com/dotnet/aspnetcore", + "enabled": true, + "tests": [ + "echo-ws", + "echo-ws-pipeline" + ], + "maintainers": [] +}