{"id":51052285,"url":"https://github.com/tunahanaliozturk/orionstream","last_synced_at":"2026-06-27T08:00:39.054Z","repository":{"id":365575442,"uuid":"1269550790","full_name":"tunahanaliozturk/OrionStream","owner":"tunahanaliozturk","description":"Server-Sent Events for ASP.NET Core: a topic-based broadcast hub with bounded per-subscriber buffering, a spec-correct SSE writer, and heartbeats.","archived":false,"fork":false,"pushed_at":"2026-06-22T08:32:13.000Z","size":95,"stargazers_count":1,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-06-22T18:27:40.329Z","etag":null,"topics":["aspnetcore","csharp","dotnet","nuget","orion","realtime","server-sent-events","sse","streaming"],"latest_commit_sha":null,"homepage":null,"language":"C#","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/tunahanaliozturk.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":"CONTRIBUTING.md","funding":"FUNDING.yml","license":"LICENSE","code_of_conduct":"CODE_OF_CONDUCT.md","threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":"docs/ROADMAP.md","authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null},"funding":{"github":"Moongazing"}},"created_at":"2026-06-14T21:13:58.000Z","updated_at":"2026-06-22T08:32:16.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/tunahanaliozturk/OrionStream","commit_stats":null,"previous_names":["tunahanaliozturk/orionstream"],"tags_count":3,"template":false,"template_full_name":null,"purl":"pkg:github/tunahanaliozturk/OrionStream","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tunahanaliozturk%2FOrionStream","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tunahanaliozturk%2FOrionStream/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tunahanaliozturk%2FOrionStream/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tunahanaliozturk%2FOrionStream/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/tunahanaliozturk","download_url":"https://codeload.github.com/tunahanaliozturk/OrionStream/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/tunahanaliozturk%2FOrionStream/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":34845749,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-06-27T02:00:06.362Z","response_time":126,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["aspnetcore","csharp","dotnet","nuget","orion","realtime","server-sent-events","sse","streaming"],"created_at":"2026-06-22T18:01:54.396Z","updated_at":"2026-06-27T08:00:39.043Z","avatar_url":"https://github.com/tunahanaliozturk.png","language":"C#","funding_links":["https://github.com/sponsors/Moongazing"],"categories":[],"sub_categories":[],"readme":"\u003cp align=\"center\"\u003e\n  \u003cimg src=\"docs/logo.png\" alt=\"OrionStream\" width=\"150\" /\u003e\n\u003c/p\u003e\n\n# OrionStream\n\n[![CI/CD](https://github.com/tunahanaliozturk/OrionStream/actions/workflows/ci-cd.yml/badge.svg)](https://github.com/tunahanaliozturk/OrionStream/actions/workflows/ci-cd.yml)\n[![NuGet](https://img.shields.io/nuget/v/OrionStream.svg)](https://www.nuget.org/packages/OrionStream/)\n\nServer-Sent Events for ASP.NET Core. Publish an event to a topic and every connected client\nsubscribed to that topic receives it, over a plain `text/event-stream` response that any browser\n`EventSource` can read with no extra client library.\n\nPart of the **Orion** family. Usable entirely on its own.\n\n---\n\n## Why\n\nSSE is the simplest way to push server events to a browser: one long-lived HTTP response, a tiny\nwire format, automatic client reconnect. The fiddly parts are the wire format (multi-line data, the\nfield order, heartbeats so proxies do not close an idle stream) and fan-out without letting a slow\nclient stall the others. OrionStream handles both: a topic hub with a bounded buffer per subscriber\nand a spec-correct writer.\n\nThe library has three independent pieces. The hub (`ISseHub`) and the formatter (`SseFormatter`)\nhave no dependency on HTTP, so both are unit-tested directly. The writer extension\n(`WriteStreamAsync`) is the only piece that touches `HttpResponse`, and it adapts a subscription to\nthe response body.\n\n---\n\n## Features\n\n- **Topic-based broadcast hub.** Publish once to a topic; every current subscriber receives the\n  event. Topics are created on first subscribe and removed when their last subscriber leaves, so an\n  idle topic costs nothing.\n- **Bounded per-subscriber buffering.** Each subscriber gets its own bounded channel in\n  `DropOldest` mode. A publish never blocks: a slow subscriber drops its own oldest event to admit\n  the newest, and a slow client degrades only its own stream.\n- **Spec-correct wire format.** `SseFormatter` renders the `text/event-stream` fields in canonical\n  order (`id`, `event`, `retry`, `data`), splits multi-line payloads across multiple `data:` lines,\n  and strips stray newlines from single-line fields.\n- **Heartbeats.** The writer sends an SSE comment line on an idle stream so proxies and load\n  balancers keep the connection open.\n- **Last-Event-ID resume.** Every published event carries a wire `id:`, either the producer-supplied\n  `ServerSentEvent.Id` or a hub-assigned topic-monotonic sequence. A bounded per-topic replay buffer\n  (`StreamOptions.ReplayBufferCapacity`, default 256, set to `0` to disable) retains the most recent\n  events, so a client that reconnects with `Subscribe(topic, lastEventId)` resumes after its\n  `Last-Event-ID` with no gap. An unknown or evicted id falls back to a from-now stream.\n- **Built-in metrics.** A `System.Diagnostics.Metrics` meter named `Moongazing.OrionStream` exposes\n  published and dropped counters and a current-subscribers gauge.\n- **Multi-targeted.** `net8.0`, `net9.0`, `net10.0`, nullable enabled, warnings as errors.\n\nSee [docs/FEATURES.md](docs/FEATURES.md) for the full surface and [docs/ROADMAP.md](docs/ROADMAP.md)\nfor where this is going.\n\n---\n\n## Install\n\n```bash\ndotnet add package OrionStream\n```\n\nThe package id is `OrionStream`; the root namespace is `Moongazing.OrionStream`. It carries a\n`FrameworkReference` to `Microsoft.AspNetCore.App`, so add it to a project that targets the ASP.NET\nCore shared framework.\n\n---\n\n## Quick start\n\nRegister the hub, its options, and diagnostics (all singletons):\n\n```csharp\nbuilder.Services.AddOrionStream(o =\u003e\n{\n    o.SubscriberCapacity = 256;\n    o.HeartbeatInterval = TimeSpan.FromSeconds(15);\n});\n```\n\nMap an endpoint that streams a topic to the client. The `MapServerSentEvents` helper wires the\nsubscribe-then-write pattern (including reading `Last-Event-ID` to resume) in one line:\n\n```csharp\napp.MapServerSentEvents(\"/events/orders\", \"orders\");\n// or derive the topic per request from a route value:\napp.MapServerSentEvents(\"/events/{topic}\", ctx =\u003e (string?)ctx.Request.RouteValues[\"topic\"]);\n```\n\nPublish from anywhere that has the hub. The typed overload serializes the payload for you:\n\n```csharp\nhub.Publish(\"orders\", order, eventName: \"order.created\", id: order.Id.ToString());\n\n// or build the event yourself with the raw string publish:\nhub.Publish(\"orders\", new ServerSentEvent\n{\n    Id = order.Id.ToString(),\n    EventName = \"order.created\",\n    Data = JsonSerializer.Serialize(order),\n});\n```\n\nThe browser side needs no client library:\n\n```js\nconst es = new EventSource(\"/events/orders\");\nes.addEventListener(\"order.created\", e =\u003e console.log(JSON.parse(e.data)));\n```\n\n---\n\n## Usage\n\n### Topics and the hub\n\n`ISseHub` is the entire producer/consumer surface:\n\n```csharp\npublic interface ISseHub\n{\n    StreamSubscription Subscribe(string topic);\n    StreamSubscription Subscribe(string topic, string? lastEventId);\n    int Publish(string topic, ServerSentEvent evt);\n    int SubscriberCount(string topic);\n}\n```\n\n- `Subscribe(topic)` returns a `StreamSubscription`. Read events off `subscription.Reader` (a\n  `ChannelReader\u003cServerSentEvent\u003e`) and dispose the subscription to unsubscribe. Disposal is\n  idempotent.\n- `Publish(topic, evt)` delivers to every current subscriber of the topic and returns the number of\n  subscribers it reached. Publishing to a topic with no subscribers returns `0` and does nothing\n  else.\n- `SubscriberCount(topic)` is the current count for a topic, handy for diagnostics or for skipping\n  serialization when nobody is listening.\n\nTopic matching is ordinal (case-sensitive). A topic is tracked lazily on first subscribe and removed\nonce its last subscriber disposes, so the topic map does not grow with idle topics.\n\n### Back-pressure and DropOldest\n\nEach subscriber gets a bounded channel sized to `SubscriberCapacity`, created with\n`BoundedChannelFullMode.DropOldest`. The consequence is that `Publish` is non-blocking by\nconstruction: a slow reader can never stall the producer or the other subscribers. When a\nsubscriber's buffer is already full at publish time, its oldest buffered event is evicted to make\nroom for the newest, and that eviction is counted in telemetry (`orionstream.dropped`).\n\n```csharp\n// With SubscriberCapacity = 2 and a reader that has not drained:\nhub.Publish(\"orders\", new ServerSentEvent { Data = \"1\" });\nhub.Publish(\"orders\", new ServerSentEvent { Data = \"2\" });\nhub.Publish(\"orders\", new ServerSentEvent { Data = \"3\" }); // evicts \"1\"; reader now sees \"2\", \"3\"\n```\n\nThis is a deliberate trade-off: OrionStream favors keeping every stream live and current over\nguaranteeing delivery of every event to a client that cannot keep up. Pick `SubscriberCapacity`\nlarge enough to ride out normal bursts; for a client that drops the connection and must recover the\nevents it missed while away, use the built-in `Last-Event-ID` resume below.\n\n### Last-Event-ID resume\n\nA browser `EventSource` remembers the `id:` of the last event it received and sends it back as the\n`Last-Event-ID` request header when it reconnects. The `Subscribe(topic, lastEventId)` overload turns\nthat header into a gap-free resume.\n\nFor this to work every event needs a wire id. The hub assigns one automatically: when a producer does\nnot set `ServerSentEvent.Id`, the hub stamps a topic-monotonic sequence as the `id:` on the wire. A\nproducer-supplied `Id` always takes precedence and round-trips through resume unchanged, so you can\nresume against your own ids (an order id, a database row version) or let the hub number events for\nyou.\n\nThe hub retains the newest `StreamOptions.ReplayBufferCapacity` events per topic (default 256). On\nreconnect it matches the client's `Last-Event-ID` against the wire id of each retained event:\n\n- A match replays only the events published after that id, then live events flow. The client misses\n  nothing, provided `StreamOptions.SubscriberCapacity` covers the replay burst: replayed events share\n  the subscriber's bounded `DropOldest` channel, so if the backlog to replay exceeds\n  `SubscriberCapacity` the oldest replayed entries are dropped. When gap-free resume matters, size\n  `SubscriberCapacity` at least as large as `ReplayBufferCapacity` (plus live headroom).\n- An unknown or evicted id (older than the buffer still holds, or one the buffer never saw) falls\n  back to a from-now stream with no replay. Resume is all-or-nothing: a client either resumes exactly\n  or starts clean, never on a partial backlog.\n\nRead the header from the request and pass it straight to `Subscribe`:\n\n```csharp\napp.MapGet(\"/events/orders\", async (HttpContext ctx, ISseHub hub, StreamOptions options) =\u003e\n{\n    var lastEventId = ctx.Request.Headers[\"Last-Event-ID\"].FirstOrDefault();\n    using var subscription = hub.Subscribe(\"orders\", lastEventId);\n    await ctx.Response.WriteStreamAsync(subscription, options.HeartbeatInterval, ctx.RequestAborted);\n});\n```\n\nSet `ReplayBufferCapacity` to `0` to disable replay entirely; every subscribe then starts from now.\nSizing the buffer is the usual trade-off: it bounds how long a client can be disconnected and still\nresume without a gap, against the memory held per active topic.\n\n### The formatter\n\n`SseFormatter` is a pure, allocation-light renderer you can use independently of the hub or the HTTP\nwriter, for example in tests:\n\n```csharp\nvar wire = SseFormatter.Format(new ServerSentEvent\n{\n    Id = \"42\",\n    EventName = \"tick\",\n    RetryMilliseconds = 3000,\n    Data = \"payload\",\n});\n// \"id: 42\\nevent: tick\\nretry: 3000\\ndata: payload\\n\\n\"\n```\n\nIt follows the HTML SSE spec: fields render in canonical order, only the fields you set are emitted,\nmulti-line `Data` becomes multiple `data:` lines (`\\r\\n`, `\\r`, and `\\n` are all normalized), and\nstray newlines in `Id`/`EventName` are stripped so they cannot break the framing. A heartbeat is the\nconstant `SseFormatter.Heartbeat` (`\": heartbeat\\n\\n\"`), a comment line that carries no event.\n\n### Subscriber lifecycle and the writer\n\n`WriteStreamAsync` is the bridge from a subscription to an `HttpResponse`. It sets the SSE response\nheaders (`Content-Type: text/event-stream`, `Cache-Control: no-cache`, `X-Accel-Buffering: no`),\nthen loops: it drains the subscription's reader to the response body, and whenever the stream is idle\nfor `heartbeatInterval` it writes a heartbeat comment so intermediaries keep the connection open. It\nreturns when the client disconnects (the cancellation token trips, typically `ctx.RequestAborted`)\nor when the subscription completes. A client disconnect is the expected exit and is handled silently.\n\nThe canonical pattern is to scope the subscription to the request with `using`, so that when\n`WriteStreamAsync` returns the subscription is disposed and the subscriber is removed from the hub:\n\n```csharp\napp.MapGet(\"/events/{topic}\", async (string topic, HttpContext ctx, ISseHub hub, StreamOptions options) =\u003e\n{\n    using var subscription = hub.Subscribe(topic);\n    await ctx.Response.WriteStreamAsync(subscription, options.HeartbeatInterval, ctx.RequestAborted);\n});\n```\n\n---\n\n## Configuration\n\n`AddOrionStream` takes an optional `Action\u003cStreamOptions\u003e`. Options are validated eagerly at\nregistration time, so an invalid value throws from `AddOrionStream`, not later at first use.\n\n```csharp\npublic sealed class StreamOptions\n{\n    public int SubscriberCapacity { get; set; } = 256;\n    public TimeSpan HeartbeatInterval { get; set; } = TimeSpan.FromSeconds(15);\n    public int ReplayBufferCapacity { get; set; } = 256;\n}\n```\n\n| Option | Default | Meaning |\n| --- | --- | --- |\n| `SubscriberCapacity` | `256` | Bounded buffer size per subscriber. Must be at least `1`. When a subscriber falls this far behind, its oldest buffered event is dropped to admit the newest. |\n| `HeartbeatInterval` | `15s` | How long a stream may be idle before the writer sends a heartbeat comment. Must be positive. |\n| `ReplayBufferCapacity` | `256` | How many of the most recent events per topic are retained for `Last-Event-ID` resume. Must be zero or greater; `0` disables replay so every subscribe starts from now. |\n\n`AddOrionStream` registers three singletons via `TryAdd`, so you can override any of them before or\nafter the call: `StreamOptions`, `StreamDiagnostics`, and `ISseHub` (implemented by `SseHub`).\n\n---\n\n## Telemetry\n\n`StreamDiagnostics` owns a `System.Diagnostics.Metrics.Meter` named `Moongazing.OrionStream`\n(also exposed as `StreamDiagnostics.MeterName`). Subscribe to it from OpenTelemetry or any\n`MeterListener`:\n\n| Instrument | Kind | Unit | Meaning |\n| --- | --- | --- | --- |\n| `orionstream.published` | Counter | `{event}` | Events published to the hub, counted once per publish (not per subscriber). Tagged with `orionstream.topic`. |\n| `orionstream.dropped` | Counter | `{event}` | Events dropped because a subscriber buffer was full at publish time. Tagged with `orionstream.topic`. |\n| `orionstream.subscribers` | Observable gauge | `{subscriber}` | Currently connected subscribers across all topics. |\n\nThe `orionstream.topic` tag (`StreamDiagnostics.TopicTagName`) slices the published and dropped\ncounters per topic. `StreamDiagnostics` also exposes an `ActivitySource` named `Moongazing.OrionStream`\nwith an `OrionStream.Publish` span and an `OrionStream.Subscribe` span, each tagged with the topic.\n\n```csharp\nbuilder.Services.AddOpenTelemetry()\n    .WithMetrics(m =\u003e m.AddMeter(StreamDiagnostics.MeterName))\n    .WithTracing(t =\u003e t.AddSource(StreamDiagnostics.MeterName));\n```\n\nA steadily climbing `orionstream.dropped` is the signal that subscribers cannot keep up: raise\n`SubscriberCapacity`, reduce publish volume, or shrink per-event payloads.\n\n---\n\n## Testing\n\nThe hub and the formatter are plain in-memory types with no HTTP dependency, so they test directly.\nThe writer is verified against a real `DefaultHttpContext` with a capturing response body.\n\n```csharp\nusing var diagnostics = new StreamDiagnostics();\nvar hub = new SseHub(new StreamOptions { SubscriberCapacity = 2 }, diagnostics);\nusing var subscription = hub.Subscribe(\"orders\");\n\nvar delivered = hub.Publish(\"orders\", new ServerSentEvent { Data = \"hello\" });\n\nAssert.Equal(1, delivered);\nAssert.True(subscription.Reader.TryRead(out var evt));\nAssert.Equal(\"hello\", evt!.Data);\n```\n\n`SseFormatter.Format` is a pure function over a `ServerSentEvent`, which makes wire-format assertions\nexact and trivial:\n\n```csharp\nAssert.Equal(\"data: hello\\n\\n\", SseFormatter.Format(new ServerSentEvent { Data = \"hello\" }));\n```\n\n---\n\n## Benchmarks\n\nIn-memory micro-benchmarks for the formatter and the hub (publish fan-out, throughput with and\nwithout the DropOldest path, and subscribe/dispose churn) live in\n`benchmarks/Moongazing.OrionStream.Benchmarks` and reference the library directly. See\n[benchmarks.md](benchmarks.md) for what each one measures. No result numbers are committed because\nthey are machine-specific; run the suite locally:\n\n```bash\ndotnet run -c Release --project benchmarks/Moongazing.OrionStream.Benchmarks -- --filter \"*\"\n```\n\n---\n\n## Design\n\n- Multi-targets `net8.0`, `net9.0`, `net10.0`.\n- `TreatWarningsAsErrors`, latest analyzers, nullable enabled, XML docs generated.\n- The hub and the SSE formatter are independent of HTTP, so both are unit-tested directly; the\n  writer extension adapts them to an `HttpResponse`.\n\n---\n\n## Versioning\n\nOrionStream is at **0.2.0**. While it is pre-1.0 the public API may still change between minor\nversions; once it reaches 1.0 it will follow [SemVer 2.0.0](https://semver.org/). See\n[CHANGELOG.md](CHANGELOG.md) for the per-release history.\n\n---\n\n## Contributing\n\nIssues and pull requests are welcome. Please read [CONTRIBUTING.md](CONTRIBUTING.md) and the\n[Code of Conduct](CODE_OF_CONDUCT.md) before opening one.\n\n## License\n\nMIT. See [LICENSE](LICENSE).\n\n## Author\n\n**Tunahan Ali Ozturk** - [GitHub](https://github.com/tunahanaliozturk)\n\u003c/content\u003e\n\u003c/invoke\u003e\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftunahanaliozturk%2Forionstream","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Ftunahanaliozturk%2Forionstream","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Ftunahanaliozturk%2Forionstream/lists"}