Skip to content
Merged
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ docker run -it --rm -p 4000:4000 \
| `HELIUS_API_KEY` | optional Helius API key added as an extra Solana RPC fallback |
| `ETH_RPC_URLS` / `BNB_RPC_URLS` / `SOL_RPC_URLS` / `BTC_ESPLORA_URLS` | optional comma-separated endpoint lists overriding the built-in chain provider pools |
| `Logging__LogLevel__Default` | log level (default `Warning`; set `Information` for per-request logs) |
| `SSR_INTERNAL_SECRET` | shared header secret that switches on the internal SSR RPC cache routes (`/private-api/ssr/*`); unset = they answer like unknown routes |
| `SSR_CACHE_BYTES` | byte budget of the SSR RPC cache, LRU beyond it (default 512 MiB) |
| `SSR_RPC_BUDGET_MS` | wall-clock budget for one SSR RPC lookup before it answers 504 while the fill completes (default `1500`) |
| `SSR_RPC_NODE_TIMEOUT_MS` | per-node timeout of the SSR RPC cache's own client, one attempt per node (default `1200`) |
| `SSR_RPC_NODES` | comma-separated node pool for that client (default: the shared pool) |
| `SSR_RPC_MAX_FILLS` / `SSR_RPC_MAX_QUEUED_FILLS` | bound on upstream fills in progress (default `64`) and on fills waiting for that bound (default `256`); beyond the latter a miss fails fast |

## Swarm

Expand Down
5 changes: 5 additions & 0 deletions dotnet/EcencyApi.Tests/EcencyApi.Tests.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@
<ProjectReference Include="../EcencyApi/EcencyApi.csproj" />
</ItemGroup>

<ItemGroup>
<!-- DefaultHttpContext for handler-level tests (SsrRpcTests). -->
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
<None Include="fixtures/**" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
Expand Down
499 changes: 499 additions & 0 deletions dotnet/EcencyApi.Tests/SsrRpcTests.cs

Large diffs are not rendered by default.

37 changes: 37 additions & 0 deletions dotnet/EcencyApi/Config.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,5 +30,42 @@ public static class Config
public static string CaptchaMode { get; } =
(Env("CAPTCHA_MODE") ?? "hard").Trim().ToLowerInvariant();

// ---- SSR RPC cache (Handlers/SsrRpc.cs) ----
// Shared secret the web tier sends on every call. Unset = the routes are
// switched off and answer exactly like unknown routes.
public static string? SsrInternalSecret { get; } = NonEmpty(Env("SSR_INTERNAL_SECRET"));

// Total bytes of cached responses kept in memory (LRU beyond that).
public static long SsrCacheBytes { get; } =
long.TryParse(Env("SSR_CACHE_BYTES"), out var b) && b >= 0 ? b : 512L * 1024 * 1024;

// Wall-clock budget for one lookup. The web tier gives up on the proxy a
// little later and falls back to its own node pool, so this must stay
// under that; a lookup that outlives it still completes and fills the cache.
public static int SsrBudgetMs { get; } =
int.TryParse(Env("SSR_RPC_BUDGET_MS"), out var ms) && ms > 0 ? ms : 1500;

// Per-node timeout for the cache's own RPC client (one attempt per node).
public static int SsrNodeTimeoutMs { get; } =
int.TryParse(Env("SSR_RPC_NODE_TIMEOUT_MS"), out var nt) && nt > 0 ? nt : 1200;

// Optional node pool for the cache's RPC client, comma-separated; defaults
// to the shared pool. Lets a deployment put its own node first.
public static string[]? SsrRpcNodes { get; } =
NonEmpty(Env("SSR_RPC_NODES"))?.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
is { Length: > 0 } nodes ? nodes : null;

// Upper bound on upstream fills in progress at once. A fill outlives the
// request budget on purpose (it still lands in the cache), so without a
// bound a slow pool plus many distinct keys would pile up detached calls.
public static int SsrMaxConcurrentFills { get; } =
int.TryParse(Env("SSR_RPC_MAX_FILLS"), out var f) && f > 0 ? f : 64;

// Upper bound on fills waiting for that gate; beyond it a miss fails fast.
public static int SsrMaxQueuedFills { get; } =
int.TryParse(Env("SSR_RPC_MAX_QUEUED_FILLS"), out var q) && q > 0 ? q : 256;

private static string? NonEmpty(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim();

private static string? Env(string name) => Environment.GetEnvironmentVariable(name);
}
48 changes: 29 additions & 19 deletions dotnet/EcencyApi/Handlers/Routes.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ namespace EcencyApi.Handlers;
/// - unmatched GET/HEAD -> 200 + the template page;
/// - unmatched other methods -> 404 + Express finalhandler "Cannot METHOD /path".
/// </summary>
public static class Routes
public static partial class Routes
{
public static void Map(WebApplication app)
{
Expand Down Expand Up @@ -168,6 +168,10 @@ public static void Map(WebApplication app)
app.MapPost("/private-api/chats-update", PrivateApi.ChatsUpdate);
app.MapPost("/private-api/channel-add", PrivateApi.ChannelAdd);

// ---- SSR RPC cache (internal, header-gated; see SsrRpc.cs) ----
app.MapPost("/private-api/ssr/rpc", SsrRpc.Rpc);
app.MapGet("/private-api/ssr/stats", SsrRpc.Stats);

// ---- Health check for docker swarm ----
app.MapGet("/healthcheck.json", async ctx =>
{
Expand All @@ -181,28 +185,34 @@ public static void Map(WebApplication app)
// ---- Fallback ----
// GET/HEAD -> the template page (Express .get("*", fallbackHandler)).
// Everything else -> Express's default finalhandler 404.
app.MapFallback(async ctx =>
app.MapFallback(Fallback);
}

/// <summary>
/// The unmatched-route response, also used by gated routes to answer exactly
/// like a route that does not exist.
/// </summary>
public static async Task Fallback(HttpContext ctx)
{
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
var method = ctx.Request.Method;
if (HttpMethods.IsGet(method) || HttpMethods.IsHead(method))
{
var method = ctx.Request.Method;
if (HttpMethods.IsGet(method) || HttpMethods.IsHead(method))
ctx.Response.StatusCode = 200;
ctx.Response.ContentType = "text/html; charset=utf-8";
if (!HttpMethods.IsHead(method))
{
ctx.Response.StatusCode = 200;
ctx.Response.ContentType = "text/html; charset=utf-8";
if (!HttpMethods.IsHead(method))
{
await ctx.Response.WriteAsync(TemplateHtml);
}
return;
await ctx.Response.WriteAsync(TemplateHtml);
}
return;
}

var message = $"Cannot {method} {EscapeHtml(EncodeUrl(ctx.Request.Path.Value ?? "/"))}";
var html =
"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n" +
"<title>Error</title>\n</head>\n<body>\n<pre>" + message + "</pre>\n</body>\n</html>\n";
ctx.Response.StatusCode = 404;
ctx.Response.ContentType = "text/html; charset=utf-8";
await ctx.Response.WriteAsync(html);
});
var message = $"Cannot {method} {EscapeHtml(EncodeUrl(ctx.Request.Path.Value ?? "/"))}";
var html =
"<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n" +
"<title>Error</title>\n</head>\n<body>\n<pre>" + message + "</pre>\n</body>\n</html>\n";
ctx.Response.StatusCode = 404;
ctx.Response.ContentType = "text/html; charset=utf-8";
await ctx.Response.WriteAsync(html);
}

/// <summary>
Expand Down
Loading
Loading