@cocoar/signalarrr 4.0.0-beta.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,215 @@
1
+ # Streaming
2
+
3
+ SignalARRR supports streaming in both directions using `IAsyncEnumerable<T>`,
4
+ `IObservable<T>`, and `ChannelReader<T>`.
5
+
6
+ ---
7
+
8
+ ## Server-to-Client Streaming
9
+
10
+ ### Define the contract
11
+
12
+ ```csharp
13
+ [SignalARRRContract]
14
+ public interface IChatHub {
15
+ IAsyncEnumerable<string> StreamMessages(CancellationToken ct);
16
+ IObservable<int> ObserveCountdown(int from);
17
+ ChannelReader<LogEntry> StreamLogs(string filter);
18
+ }
19
+ ```
20
+
21
+ ### Implement on the server
22
+
23
+ ```csharp
24
+ public class ChatMethods : ServerMethods<ChatHub>, IChatHub {
25
+
26
+ // IAsyncEnumerable — preferred for most streaming scenarios
27
+ public async IAsyncEnumerable<string> StreamMessages(
28
+ [EnumeratorCancellation] CancellationToken ct) {
29
+ while (!ct.IsCancellationRequested) {
30
+ yield return $"Message at {DateTime.Now:HH:mm:ss}";
31
+ await Task.Delay(1000, ct);
32
+ }
33
+ }
34
+
35
+ // IObservable — for Rx-based pipelines
36
+ public IObservable<int> ObserveCountdown(int from) {
37
+ return Observable.Interval(TimeSpan.FromSeconds(1))
38
+ .Take(from)
39
+ .Select(i => from - (int)i);
40
+ }
41
+
42
+ // ChannelReader — for producer/consumer patterns
43
+ public ChannelReader<LogEntry> StreamLogs(string filter) {
44
+ var channel = Channel.CreateUnbounded<LogEntry>();
45
+ _ = WriteLogsAsync(channel.Writer, filter);
46
+ return channel.Reader;
47
+ }
48
+
49
+ private async Task WriteLogsAsync(ChannelWriter<LogEntry> writer, string filter) {
50
+ try {
51
+ await foreach (var log in _logService.Watch(filter)) {
52
+ await writer.WriteAsync(log);
53
+ }
54
+ } finally {
55
+ writer.Complete();
56
+ }
57
+ }
58
+ }
59
+ ```
60
+
61
+ ### Consume on the client
62
+
63
+ ```csharp
64
+ var chat = connection.GetTypedMethods<IChatHub>();
65
+
66
+ // IAsyncEnumerable — natural async iteration
67
+ var cts = new CancellationTokenSource();
68
+ await foreach (var msg in chat.StreamMessages(cts.Token)) {
69
+ Console.WriteLine(msg);
70
+ if (shouldStop) cts.Cancel();
71
+ }
72
+
73
+ // IObservable — Rx subscription
74
+ chat.ObserveCountdown(10).Subscribe(
75
+ count => Console.WriteLine($"Countdown: {count}"),
76
+ () => Console.WriteLine("Done!"));
77
+
78
+ // ChannelReader — read from channel
79
+ var reader = chat.StreamLogs("error");
80
+ while (await reader.WaitToReadAsync()) {
81
+ while (reader.TryRead(out var log)) {
82
+ Console.WriteLine(log);
83
+ }
84
+ }
85
+ ```
86
+
87
+ ---
88
+
89
+ ## Client-to-Server Streaming (Server-Initiated)
90
+
91
+ The server can request a stream from the client. The client returns
92
+ `IAsyncEnumerable<T>` and SignalARRR handles the wire protocol automatically.
93
+
94
+ ### Define the client contract
95
+
96
+ ```csharp
97
+ [SignalARRRContract]
98
+ public interface IDataClient {
99
+ IAsyncEnumerable<int> StreamNumbers(int count);
100
+ Task<string> GetStatus();
101
+ }
102
+ ```
103
+
104
+ ### Implement on the client
105
+
106
+ ```csharp
107
+ public class DataClientImpl : IDataClient {
108
+ public async IAsyncEnumerable<int> StreamNumbers(int count) {
109
+ for (int i = 0; i < count; i++) {
110
+ yield return i;
111
+ await Task.Delay(100);
112
+ }
113
+ }
114
+
115
+ public Task<string> GetStatus() => Task.FromResult("OK");
116
+ }
117
+
118
+ // Register the implementation
119
+ connection.MessageHandler.RegisterInterface<IDataClient, DataClientImpl>();
120
+ ```
121
+
122
+ ### Request the stream from the server
123
+
124
+ ```csharp
125
+ public class DataMethods : ServerMethods<MyHub> {
126
+ public async Task ProcessClientData() {
127
+ // Get typed proxy for the calling client
128
+ var client = ClientContext.GetTypedMethods<IDataClient>();
129
+
130
+ // Stream data from the client
131
+ await foreach (var number in client.StreamNumbers(100)) {
132
+ Logger.LogInformation("Received: {Number}", number);
133
+ }
134
+ }
135
+ }
136
+ ```
137
+
138
+ ### How it works internally
139
+
140
+ 1. Server calls `StreamNumbers(100)` on the client proxy
141
+ 2. Proxy sends a `ServerRequestMessage` with a `StreamId` to the client
142
+ 3. Client invokes `DataClientImpl.StreamNumbers(100)`
143
+ 4. Client enumerates the `IAsyncEnumerable<int>` and sends each item back
144
+ to the server via `StreamItemToServer(streamId, item)`
145
+ 5. Client signals completion via `StreamCompleteToServer(streamId, null)`
146
+ 6. Server reads items from a `Channel<object>` managed by `ServerStreamManager`
147
+ 7. Server receives items as `IAsyncEnumerable<T>` through the proxy
148
+
149
+ ---
150
+
151
+ ## Cancellation
152
+
153
+ ### Server-to-client stream cancellation
154
+
155
+ Pass a `CancellationToken` to the streaming method. When cancelled, the server
156
+ stops the stream:
157
+
158
+ ```csharp
159
+ var cts = new CancellationTokenSource(TimeSpan.FromSeconds(30));
160
+ await foreach (var msg in chat.StreamMessages(cts.Token)) {
161
+ Console.WriteLine(msg);
162
+ }
163
+ // Stream stops after 30 seconds
164
+ ```
165
+
166
+ On the server side, use `[EnumeratorCancellation]` on the `CancellationToken`
167
+ parameter:
168
+
169
+ ```csharp
170
+ public async IAsyncEnumerable<string> StreamMessages(
171
+ [EnumeratorCancellation] CancellationToken ct) {
172
+ // ct is cancelled when the client disconnects or cancels
173
+ }
174
+ ```
175
+
176
+ ### Server-to-client CancellationToken propagation
177
+
178
+ When the server calls a client method, it can pass a `CancellationToken` that
179
+ propagates to the client:
180
+
181
+ ```csharp
182
+ // Server side
183
+ var cts = new CancellationTokenSource();
184
+ var client = ClientContext.GetTypedMethods<IWorkerClient>();
185
+ _ = client.DoLongWork(cts.Token); // Token propagated to client
186
+
187
+ // Later...
188
+ cts.Cancel(); // Client's CancellationToken is cancelled remotely
189
+ ```
190
+
191
+ ```csharp
192
+ // Client side
193
+ public class WorkerImpl : IWorkerClient {
194
+ public async Task DoLongWork(CancellationToken ct) {
195
+ while (!ct.IsCancellationRequested) {
196
+ await Task.Delay(1000, ct);
197
+ // Cancelled when server calls cts.Cancel()
198
+ }
199
+ }
200
+ }
201
+ ```
202
+
203
+ ---
204
+
205
+ ## Return type mapping
206
+
207
+ | Contract return type | Proxy dispatch | Wire protocol |
208
+ |---|---|---|
209
+ | `IAsyncEnumerable<T>` | `StreamAsync<T>()` | `StreamMessage` |
210
+ | `ChannelReader<T>` | `StreamAsync<T>()` → `ToChannelReader()` | `StreamMessage` |
211
+ | `IObservable<T>` | `StreamAsync<T>()` → `ToObservable()` | `StreamMessage` |
212
+ | `Task<T>` | `InvokeAsync<T>()` | `InvokeMessageResult` |
213
+ | `Task` | `SendAsync()` | `InvokeMessage` |
214
+ | `void` | `Send()` | `SendMessage` |
215
+ | `T` (sync) | `Invoke<T>()` | `InvokeMessageResult` |
package/package.json ADDED
@@ -0,0 +1,43 @@
1
+ {
2
+ "name": "@cocoar/signalarrr",
3
+ "version": "4.0.0-beta.15",
4
+ "description": "TypeScript client for SignalARRR — type-safe RPC over SignalR",
5
+ "type": "module",
6
+ "exports": {
7
+ ".": {
8
+ "import": "./dist/index.js",
9
+ "require": "./dist/index.cjs"
10
+ }
11
+ },
12
+ "main": "./dist/index.cjs",
13
+ "module": "./dist/index.js",
14
+ "types": "./dist/index.d.ts",
15
+ "scripts": {
16
+ "build": "tsup",
17
+ "dev": "tsup --watch",
18
+ "test": "vitest run",
19
+ "test:watch": "vitest",
20
+ "prepack": "node scripts/copy-assets.cjs",
21
+ "postinstall": "node scripts/postinstall.cjs"
22
+ },
23
+ "files": [
24
+ "dist/",
25
+ "skills/",
26
+ "docs/",
27
+ "scripts/"
28
+ ],
29
+ "author": "Bernhard Windisch",
30
+ "license": "ISC",
31
+ "dependencies": {
32
+ "@microsoft/signalr": "^10.0.0",
33
+ "@microsoft/signalr-protocol-msgpack": "^10.0.0"
34
+ },
35
+ "devDependencies": {
36
+ "tsup": "^8.0.0",
37
+ "typescript": "^5.7.0",
38
+ "vitest": "^4.1.0"
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ }
43
+ }
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+ /**
3
+ * Runs at `prepack` (before npm pack / npm publish).
4
+ * Copies the repo-root skills/ and docs/ into the package directory so they
5
+ * are included in the published tarball.
6
+ * Safe to run outside the repo — exits silently if sources are not found.
7
+ */
8
+ const { existsSync, mkdirSync, cpSync, rmSync } = require('node:fs');
9
+ const { join } = require('node:path');
10
+
11
+ const pkgRoot = join(__dirname, '..');
12
+ const repoRoot = join(__dirname, '../../..');
13
+
14
+ const skillsSrc = join(repoRoot, 'skills', 'signalarrr');
15
+ const docsSrc = join(repoRoot, 'docs');
16
+ const skillsDst = join(pkgRoot, 'skills', 'signalarrr');
17
+ const docsDst = join(pkgRoot, 'docs');
18
+
19
+ if (!existsSync(skillsSrc) || !existsSync(docsSrc)) {
20
+ console.log('[signalarrr] prepack: source assets not found — skipping copy');
21
+ process.exit(0);
22
+ }
23
+
24
+ // Clean and copy
25
+ if (existsSync(skillsDst)) rmSync(skillsDst, { recursive: true, force: true });
26
+ if (existsSync(docsDst)) rmSync(docsDst, { recursive: true, force: true });
27
+
28
+ mkdirSync(skillsDst, { recursive: true });
29
+ mkdirSync(docsDst, { recursive: true });
30
+
31
+ cpSync(skillsSrc, skillsDst, { recursive: true });
32
+ cpSync(docsSrc, docsDst, { recursive: true });
33
+
34
+ console.log('[signalarrr] prepack: assets copied');
@@ -0,0 +1,42 @@
1
+ 'use strict';
2
+ /**
3
+ * Runs automatically after `npm install` in a consuming project.
4
+ * Mirrors the MSBuild buildTransitive targets behaviour:
5
+ * - Only copies if .claude / .github already exists (no folder pollution)
6
+ * - Copies SKILL.md → {agentDir}/skills/signalarrr/
7
+ * - Copies docs/ → {agentDir}/skills/signalarrr/references/
8
+ */
9
+ const { existsSync, mkdirSync, cpSync } = require('node:fs');
10
+ const { join } = require('node:path');
11
+
12
+ const pkgRoot = join(__dirname, '..');
13
+
14
+ // INIT_CWD is set by npm to the directory where `npm install` was invoked.
15
+ const projectRoot = process.env.INIT_CWD ?? process.env.npm_config_local_prefix;
16
+
17
+ // Guard: not a consuming project install (e.g. `npm install` run inside this package itself)
18
+ if (!projectRoot || projectRoot === pkgRoot) process.exit(0);
19
+
20
+ const skillsSrc = join(pkgRoot, 'skills', 'signalarrr');
21
+ const docsSrc = join(pkgRoot, 'docs');
22
+
23
+ // Guard: assets were not bundled (dev build without prepack)
24
+ if (!existsSync(skillsSrc)) process.exit(0);
25
+
26
+ function copySkills(agentDir) {
27
+ if (!existsSync(agentDir)) return;
28
+
29
+ const skillsDst = join(agentDir, 'skills', 'signalarrr');
30
+ const refsDst = join(agentDir, 'skills', 'signalarrr', 'references');
31
+
32
+ mkdirSync(skillsDst, { recursive: true });
33
+ mkdirSync(refsDst, { recursive: true });
34
+
35
+ cpSync(skillsSrc, skillsDst, { recursive: true, force: true });
36
+ cpSync(docsSrc, refsDst, { recursive: true, force: true });
37
+
38
+ console.log(`[signalarrr] Skills copied to ${skillsDst}`);
39
+ }
40
+
41
+ copySkills(join(projectRoot, '.claude'));
42
+ copySkills(join(projectRoot, '.github'));
@@ -0,0 +1,154 @@
1
+ ---
2
+ name: signalarrr
3
+ description: >
4
+ Typed bidirectional RPC over SignalR using Cocoar.SignalARRR.
5
+ Use when working with HARRR hubs, ServerMethods, HARRRConnection,
6
+ [SignalARRRContract] interfaces, streaming (IAsyncEnumerable, IObservable,
7
+ ChannelReader), server-to-client calls, SignalARRR authorization,
8
+ or the @cocoar/signalarrr TypeScript/JavaScript npm client.
9
+ metadata:
10
+ author: Bernhard Windisch
11
+ version: "4.0"
12
+ ---
13
+
14
+ # Cocoar.SignalARRR
15
+
16
+ SignalARRR extends ASP.NET Core SignalR with typed bidirectional RPC.
17
+ Both server and client can call each other's methods through shared interfaces,
18
+ with compile-time proxy generation, streaming, cancellation propagation, and
19
+ ASP.NET Core authorization.
20
+
21
+ ## When to use this skill
22
+
23
+ Use this skill when:
24
+ - Setting up a HARRR hub or ServerMethods class
25
+ - Creating or configuring a HARRRConnection (C# or TypeScript client)
26
+ - Defining `[SignalARRRContract]` interfaces for typed RPC
27
+ - Implementing streaming with IAsyncEnumerable, IObservable, or ChannelReader
28
+ - Server needs to call client methods and await responses
29
+ - Configuring authorization on hub methods
30
+ - Using the `@cocoar/signalarrr` npm package in a JavaScript/TypeScript project
31
+ - Troubleshooting SignalARRR connection or invocation issues
32
+
33
+ ## Package structure
34
+
35
+ | Package | Purpose |
36
+ |---|---|
37
+ | `Cocoar.SignalARRR.Server` | Server-side: HARRR hub, ServerMethods, auth, ClientManager |
38
+ | `Cocoar.SignalARRR.Client` | Client-side .NET: HARRRConnection, typed proxies |
39
+ | `Cocoar.SignalARRR.Contracts` | Shared: `[SignalARRRContract]` attribute + source generator (reference from shared interface projects) |
40
+ | `Cocoar.SignalARRR.Common` | Wire protocol types (referenced automatically) |
41
+ | `Cocoar.SignalARRR.ProxyGenerator` | Proxy factory infrastructure (referenced automatically) |
42
+ | `Cocoar.SignalARRR.DynamicProxy` | Opt-in runtime proxy fallback via DispatchProxy |
43
+ | `Cocoar.SignalARRR.SourceGenerator` | Roslyn source generator (bundled in Contracts) |
44
+ | `@cocoar/signalarrr` (npm) | TypeScript/JavaScript client: `HARRRConnection`, `invoke`, `send`, `stream`, `onServerMethod` |
45
+
46
+ ## Quick start
47
+
48
+ ### 1. Define shared interfaces
49
+
50
+ In your shared project, reference `Cocoar.SignalARRR.Contracts`:
51
+
52
+ ```csharp
53
+ [SignalARRRContract]
54
+ public interface IChatHub {
55
+ Task SendMessage(string user, string message);
56
+ Task<List<string>> GetHistory();
57
+ IAsyncEnumerable<string> StreamMessages(CancellationToken ct);
58
+ }
59
+
60
+ [SignalARRRContract]
61
+ public interface IChatClient {
62
+ void ReceiveMessage(string user, string message);
63
+ Task<string> GetClientName();
64
+ }
65
+ ```
66
+
67
+ ### 2. Server setup
68
+
69
+ ```csharp
70
+ // Program.cs
71
+ services.AddSignalR();
72
+ services.AddSignalARRR(options => options
73
+ .AddServerMethodsFrom(typeof(Program).Assembly));
74
+
75
+ app.UseRouting();
76
+ app.UseAuthentication(); // if using auth
77
+ app.UseAuthorization(); // if using auth
78
+ app.MapHARRRController<ChatHub>("/chathub");
79
+ ```
80
+
81
+ ```csharp
82
+ // Hub
83
+ public class ChatHub : HARRR {
84
+ public ChatHub(IServiceProvider sp) : base(sp) { }
85
+ }
86
+
87
+ // Server methods (auto-discovered)
88
+ public class ChatMethods : ServerMethods<ChatHub>, IChatHub {
89
+ public Task SendMessage(string user, string message) {
90
+ Clients.All.SendAsync("ReceiveMessage", user, message);
91
+ return Task.CompletedTask;
92
+ }
93
+
94
+ public Task<List<string>> GetHistory() => Task.FromResult(new List<string>());
95
+
96
+ public async IAsyncEnumerable<string> StreamMessages(
97
+ [EnumeratorCancellation] CancellationToken ct) {
98
+ while (!ct.IsCancellationRequested) {
99
+ yield return $"msg-{DateTime.Now:ss}";
100
+ await Task.Delay(1000, ct);
101
+ }
102
+ }
103
+ }
104
+ ```
105
+
106
+ ### 3. Client setup
107
+
108
+ ```csharp
109
+ var connection = HARRRConnection.Create(builder => {
110
+ builder.WithUrl("https://localhost:5001/chathub");
111
+ });
112
+
113
+ await connection.StartAsync();
114
+
115
+ // Typed calls
116
+ var chat = connection.GetTypedMethods<IChatHub>();
117
+ await chat.SendMessage("Alice", "Hello!");
118
+ var history = await chat.GetHistory();
119
+
120
+ // Streaming
121
+ await foreach (var msg in chat.StreamMessages(cancellationToken)) {
122
+ Console.WriteLine(msg);
123
+ }
124
+ ```
125
+
126
+ ### 4. Server-to-client calls
127
+
128
+ ```csharp
129
+ // Inside a ServerMethods class — use ClientContext
130
+ var client = ClientContext.GetTypedMethods<IChatClient>();
131
+ var name = await client.GetClientName();
132
+
133
+ // Outside hub context — inject ClientManager
134
+ public class NotificationService {
135
+ private readonly ClientManager _clients;
136
+ public NotificationService(ClientManager clients) => _clients = clients;
137
+
138
+ public void NotifyClient(string connectionId) {
139
+ var client = _clients.GetTypedMethods<IChatClient>(connectionId);
140
+ client.ReceiveMessage("System", "Hello from server!");
141
+ }
142
+ }
143
+ ```
144
+
145
+ ## Reference documentation
146
+
147
+ For detailed API documentation, see:
148
+ - [Getting Started](references/getting-started.md) — full setup walkthrough
149
+ - [Server API](references/server-api.md) — HARRR, ServerMethods, ClientManager, AddSignalARRR
150
+ - [Client API](references/client-api.md) — HARRRConnection, typed proxies, event handlers
151
+ - [Streaming](references/streaming.md) — IAsyncEnumerable, IObservable, ChannelReader patterns
152
+ - [Authorization](references/authorization.md) — [Authorize], hub-level inheritance, token flow
153
+ - [Proxy Generation](references/proxy-generation.md) — [SignalARRRContract], source generator, DynamicProxy
154
+ - [Migration from v2.x](references/migration-v4.md) — breaking changes and upgrade guide