{"id":17030101,"url":"https://github.com/rob-blackbourn/jetblack.network","last_synced_at":"2025-04-12T12:12:10.634Z","repository":{"id":31278083,"uuid":"34839978","full_name":"rob-blackbourn/JetBlack.Network","owner":"rob-blackbourn","description":"An experiment in using Rx (reactive extensions) with network sockets for TCP in C#.","archived":false,"fork":false,"pushed_at":"2017-10-11T14:07:34.000Z","size":73,"stargazers_count":17,"open_issues_count":1,"forks_count":8,"subscribers_count":10,"default_branch":"master","last_synced_at":"2025-04-12T12:12:05.273Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":"","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/rob-blackbourn.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":"License.txt","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null}},"created_at":"2015-04-30T07:13:52.000Z","updated_at":"2020-10-25T12:24:48.000Z","dependencies_parsed_at":"2022-09-09T07:40:27.920Z","dependency_job_id":null,"html_url":"https://github.com/rob-blackbourn/JetBlack.Network","commit_stats":null,"previous_names":[],"tags_count":0,"template":false,"template_full_name":null,"repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rob-blackbourn%2FJetBlack.Network","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rob-blackbourn%2FJetBlack.Network/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rob-blackbourn%2FJetBlack.Network/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/rob-blackbourn%2FJetBlack.Network/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/rob-blackbourn","download_url":"https://codeload.github.com/rob-blackbourn/JetBlack.Network/tar.gz/refs/heads/master","host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":248565078,"owners_count":21125417,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2022-07-04T15:15:14.044Z","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":[],"created_at":"2024-10-14T08:04:17.042Z","updated_at":"2025-04-12T12:12:10.615Z","avatar_url":"https://github.com/rob-blackbourn.png","language":"C#","readme":"# JetBlack.Network\n\nAn experiment in using reactive extensions with network sockets over TCP.\n\nFour versions are implemented:\n\n1.  Use [`TcpListener`](https://msdn.microsoft.com/library/system.net.sockets.tcplistener.aspx)/[`TcpClient`](https://msdn.microsoft.com/library/system.net.sockets.tcpclient.aspx) classes with asynchronous listen, connect, and stream methods.\n2.  Use [`Socket`](https://msdn.microsoft.com/library/system.net.sockets.socket.aspx) as the driving class providing asynchronous listen and connect methods, but using with asynchronous stream methods.\n3.  Use [`Socket`](https://msdn.microsoft.com/library/system.net.sockets.socket.aspx) as the driving class providing asynchronous methods for listen, connect, send and receive.\n4.  Use [`Socket`](https://msdn.microsoft.com/library/system.net.sockets.socket.aspx) as the driving class with non-blocking sockets and a select loop.\n\n## News\n\n### 2015-09-04\n\nI have folded in the changes I made while using these classes. The major change\nis that the `ByteBuffer` has been replaced by `System.ArraySegment\u003cbyte\u003e`\nwhich has effectively the same functionality. This means I can use the methods\non `Socket` which take `IList\u003cArraySegment\u003cbyte\u003e\u003e`, and delete a class! \n\nThe `DisposableBuffer` has been replaced by a generic wrapper class\n`DisposableValue`.\n\nI hope these changes don't screw things up for people. I think this solution is\nneater, and more sympathetic to the underlying classes. I do like to delete\ncode whenever possible!\n\n## Description\n\n### Listening\n\nThe natural approach for a listener would be to subscribe an endpoint, and\nreceive clients as they connect. This is achieved by an extension method\n`ToListenerObservable` which produces an observable of the form:\n`IObservable\u003cTcpClient\u003e` or `IObservable\u003cSocket\u003e`. So you might do the\nfollowing:\n\n```cs\nnew IPEndPoint(IPAddress.Parse(\"127.0.0.1\"), 9211)\n    .ToListenerObservable(10)\n    .Subscribe(socket =\u003e DoSomething(socket));\n```\n\nThe `10` is the backlog.\n\n### Clients\n\nClients read with an `IObservable\u003cArraySegment\u003cbyte\u003e\u003e` and write with an `IObserver\u003cArraySegment\u003cbyte\u003e\u003e` and are created by\nextension methods which take a [`Socket`](https://msdn.microsoft.com/library/system.net.sockets.socket.aspx)\nor [`TcpClient`](https://msdn.microsoft.com/library/system.net.sockets.tcpclient.aspx).\nThere is also an `ISubject\u003cArraySegment\u003cbyte\u003e, ArraySegment\u003cbyte\u003e\u003e` for\nreading and writing with the same object. So you might do the following:\n\n```cs\nsocket.ToClientObservable(1024)\n    .Subscribe(buffer =\u003e DoSomething(buffer));\n```\n\nThe `ArraySegment\u003cbyte\u003e` class has a buffer and a length (the buffer may not be full). The `1024` argument was the size\nof the buffer to create. typically the extension method will also take a [`CancellationToken`](https://msdn.microsoft.com/library/system.threading.cancellationtoken.aspx) as an argument.\n\n### Frame Clients\n\nFrame Clients follow the same pattern to the clients, but use a [`DisposableValue\u003cArraySegment\u003cbyte\u003e\u003e`](https://github.com/rob-blackbourn/JetBlack.Network/blob/master/JetBlack.Network/Common/DisposableValue.cs)\nand send/receive the length of the buffer. This ensures the full message is\nreceived. They also take a [`BufferManager`](https://msdn.microsoft.com/library/system.servicemodel.channels.buffermanager.aspx)\nto reduce garbage collection.\n\n## Connectors\n\nThe client connection can be performed asynchronously. ClientConnectors are `IObservable\u003cSocket\u003e` or `IObservable\u003cTcpClient\u003e` and\nare created by extension methods which take [`IPEndPoint`](https://msdn.microsoft.com/library/system.net.ipendpoint.aspx). So you might do the following:\n\n```cs\nnew IPEndPoint(IPAddress.Parse(\"127.0.0.1\"), 9211)\n    .ToConnectObservable()\n    .Subscribe(socket =\u003e DoSomething(socket));\n```\n    \n## Examples\n\nFor each implementation there is an example echo client and server. The following\nshows the RxSocket implementation.\n\n### Echo Server\n\n```cs\nvar endpoint = ProgramArgs.Parse(args, new[] { \"127.0.0.1:9211\" }).EndPoint;\n\nvar cts = new CancellationTokenSource();\n\nendpoint.ToListenerObservable(10)\n    .ObserveOn(TaskPoolScheduler.Default)\n    .Subscribe(\n        client =\u003e\n            client.ToClientObservable(1024, SocketFlags.None)\n                .Subscribe(client.ToClientObserver(1024, SocketFlags.None), cts.Token),\n        error =\u003e Console.WriteLine(\"Error: \" + error.Message),\n        () =\u003e Console.WriteLine(\"OnCompleted\"),\n        cts.Token);\n\nConsole.WriteLine(\"Press \u003cENTER\u003e to quit\");\nConsole.ReadLine();\n\ncts.Cancel();\n```\n\n### Echo Client\n\n```cs\nvar endpoint = ProgramArgs.Parse(args, new[] { \"127.0.0.1:9211\" }).EndPoint;\n\nvar cts = new CancellationTokenSource();\nvar bufferManager = BufferManager.CreateBufferManager(2 \u003c\u003c 16, 2 \u003c\u003c 8);\n\nvar frameClientSubject = endpoint.ToFrameClientSubject(SocketFlags.None, bufferManager, cts.Token);\n\nvar observerDisposable =\n    frameClientSubject\n        .ObserveOn(TaskPoolScheduler.Default)\n        .Subscribe(\n            disposableBuffer =\u003e\n            {\n                Console.WriteLine(\"Read: \" + Encoding.UTF8.GetString(disposableBuffer.Value.Array, 0, disposableBuffer.Value.Count));\n                disposableBuffer.Dispose();\n            },\n            error =\u003e Console.WriteLine(\"Error: \" + error.Message),\n            () =\u003e Console.WriteLine(\"OnCompleted: FrameReceiver\"));\n\nConsole.In.ToLineObservable()\n    .Subscribe(\n        line =\u003e\n        {\n            var writeBuffer = Encoding.UTF8.GetBytes(line);\n            frameClientSubject.OnNext(DisposableValue.Create(new ArraySegment\u003cbyte\u003e(writeBuffer, 0, writeBuffer.Length), Disposable.Empty));\n        },\n        error =\u003e Console.WriteLine(\"Error: \" + error.Message),\n        () =\u003e Console.WriteLine(\"OnCompleted: LineReader\"));\n\nobserverDisposable.Dispose();\n\ncts.Cancel();\n```\n\n## Implementation\n\n### RxTcp\n\n#### Listening\n\nThis implementation is the most straightforward. The [`TcpListener`](https://msdn.microsoft.com/library/system.net.sockets.tcplistener.aspx)\nand [`TcpClient`](https://msdn.microsoft.com/library/system.net.sockets.tcpclient.aspx)\nclasses have asynchronous methods which can be used with `await` when\nconnecting and listening. The provide a\n[`NetworkStream`](https://msdn.microsoft.com/library/system.net.sockets.networkstream.aspx)\nwhich implement asynchronous methods declared by [`Stream`](https://msdn.microsoft.com/library/system.io.stream.aspx).\n\nThe listen is implemented in the following manner:\n\n```cs\npublic static IObservable\u003cTcpClient\u003e ToListenerObservable(this IPEndPoint endpoint, int backlog)\n{\n    return new TcpListener(endpoint).ToListenerObservable(backlog);\n}\n\npublic static IObservable\u003cTcpClient\u003e ToListenerObservable(this TcpListener listener, int backlog)\n{\n    return Observable.Create\u003cTcpClient\u003e(async (observer, token) =\u003e\n    {\n        listener.Start(backlog);\n\n        try\n        {\n            while (!token.IsCancellationRequested)\n                observer.OnNext(await listener.AcceptTcpClientAsync());\n\n            observer.OnCompleted();\n\n            listener.Stop();\n        }\n        catch (Exception error)\n        {\n            observer.OnError(error);\n        }\n    });\n}\n```\n\nNote that the observable factory method used is the asynchonous version which\nprovides a cancellation token. We can use this to control exit from the listen\nloop and produce the `OnCompleted` action.\n\n#### Connecting\n\nConnecting works in a similar manner to listening. We observe on and endpoint\nand receive a client.\n\n```cs\npublic static IObservable\u003cTcpClient\u003e ToConnectObservable(this IPEndPoint endpoint)\n{\n    return Observable.Create\u003cTcpClient\u003e(async (observer, token) =\u003e\n    {\n        try\n        {\n            var client = new TcpClient();\n            await client.ConnectAsync(endpoint.Address, endpoint.Port);\n            token.ThrowIfCancellationRequested();\n            observer.OnNext(client);\n            observer.OnCompleted();\n        }\n        catch (Exception error)\n        {\n            observer.OnError(error);\n        }\n    });\n}\n```\n\nAs with the listener we use the asynchronous factory method. As the connect may\ntake some time I have added a cancellation token check after the connection\nreturns.\n\n#### Reading and writing\n\nI have implemented two readers and writers. One for bytes, and another for\n\"frames\" which are discussed below. Note that when byte arrays are sent and\nreceived they may be fragmented (split into separate blocks).\n\nIt is often more efficient to manage the byte arrays in a pool. When we do\nthis the buffers may be larger than the payload, so I use `ArraySegment\u003cbyte\u003e`\nto hold the byte array and payload length.\n\nThe clients are thin wrappers around the streams:\n\n```cs\npublic static ISubject\u003cArraySegment\u003cbyte\u003e, ArraySegment\u003cbyte\u003e\u003e ToClientSubject(this TcpClient client, int size, CancellationToken token)\n{\n    return Subject.Create(client.ToClientObserver(token), client.ToClientObservable(size));\n}\n\npublic static IObservable\u003cArraySegment\u003cbyte\u003e\u003e ToClientObservable(this TcpClient client, int size)\n{\n    return client.GetStream().ToStreamObservable(size);\n}\n\npublic static IObserver\u003cArraySegment\u003cbyte\u003e\u003e ToClientObserver(this TcpClient client, CancellationToken token)\n{\n    return client.GetStream().ToStreamObserver(token);\n}\n```\n\nThe stream observer (writer) is the most straightforward as the write method\nguarantees to send the entire buffer.\n\n```cs\npublic static IObserver\u003cArraySegment\u003cbyte\u003e\u003e ToStreamObserver(this Stream stream, CancellationToken token)\n{\n    return Observer.Create\u003cArraySegment\u003cbyte\u003e\u003e(async buffer =\u003e\n    {\n        await stream.WriteAsync(buffer.Array, buffer.Offset, buffer.Count, token);\n    });\n}\n```\n\nThe stream observable follows a similar pattern to the previous observables.\n\n```cs\npublic static IObservable\u003cArraySegment\u003cbyte\u003e\u003e ToStreamObservable(this Stream stream, int size)\n{\n    return Observable.Create\u003cArraySegment\u003cbyte\u003e\u003e(async (observer, token) =\u003e\n    {\n        var buffer = new byte[size];\n\n        try\n        {\n            while (!token.IsCancellationRequested)\n            {\n                var received = await stream.ReadAsync(buffer, 0, size, token);\n                if (received == 0)\n                    break;\n\n                observer.OnNext(new ArraySegment\u003cbyte\u003e(buffer, 0, received));\n            }\n\n            observer.OnCompleted();\n        }\n        catch (Exception error)\n        {\n            observer.OnError(error);\n        }\n    });\n}\n```\n\nI have made a decision to create a dedicated buffer for each observable. This\nmay not be what you want. An example using managed buffers can be seen below.\n\nNote that the number of bytes read may be less than the size of the buffer.\n\nThe client is used in the echo server examples to read from the socket and write\nit back to the client. The server doesn't need to know anything about the\nmessage size or content so the client implementations are ideal. It simply\nforwards what it receives back to the client. Here is a slightly simplified\nversion of the code.\n\n```cs\nendpoint.ToListenerObservable(10)\n    .ObserveOn(TaskPoolScheduler.Default)\n    .Subscribe(\n        client =\u003e\n            client.ToClientObservable(1024)\n                .Subscribe(client.ToClientObserver(cts.Token), token),\n        error =\u003e Console.WriteLine(\"Error: \" + error.Message),\n        () =\u003e Console.WriteLine(\"OnCompleted\"),\n        token);\n```\n\nNote how we can use the rx `ObserveOn` method to handle the client thread\ncreation.\n\nThe frame clients manage fragmentation by sending/receiving the length of the\nbyte array, before sending the array itself. Because what is read or written is\nnow of indeterminate length I use managed buffers. With managed buffers there\nmust be a mechanism to return the buffer to the pool. To achieve this we use a\ndisposable wrapper around the buffer.\n\n```cs\n// Factory\npublic static class DisposableValue\n{\n    public static DisposableValue\u003cT\u003e Create\u003cT\u003e(T value, IDisposable disposable)\n    {\n        return new DisposableValue\u003cT\u003e(value, disposable);\n    }\n}\n\npublic struct DisposableValue\u003cT\u003e : IDisposable, IEquatable\u003cDisposableValue\u003cT\u003e\u003e\n{\n    private IDisposable _disposable;\n\n    public static readonly DisposableValue\u003cT\u003e Empty;\n        \n    public DisposableValue(T value, IDisposable disposable) : this()\n    {\n        Value = value;\n        _disposable = disposable;\n    }\n\n    public T Value { get; private set; }\n\n    public override int GetHashCode()\n    {\n        return Equals(Value, default(T)) ? 0 : Value.GetHashCode();\n    }\n\n    public override bool Equals(object obj)\n    {\n        return obj is DisposableValue\u003cT\u003e \u0026\u0026 Equals((DisposableValue\u003cT\u003e)obj);\n    }\n\n    public bool Equals(DisposableValue\u003cT\u003e other)\n    {\n        return Equals(Value, other.Value) \u0026\u0026 Equals(_disposable, other._disposable);\n    }\n\n    public static bool operator ==(DisposableValue\u003cT\u003e a, DisposableValue\u003cT\u003e b)\n    {\n        return a.Equals(b);\n    }\n\n    public static bool operator !=(DisposableValue\u003cT\u003e a, DisposableValue\u003cT\u003e b)\n    {\n        return !a.Equals(b);\n    }\n\n    public void Dispose()\n    {\n        IDisposable disposable = Interlocked.CompareExchange\u003cIDisposable\u003e(ref _disposable, null, _disposable);\n        if (disposable != null)\n            disposable.Dispose();\n    }\n}\n```\n\nThe frame clients simply delegate the behaviour to their streams.\n\n```cs\npublic static ISubject\u003cDisposableValue\u003cArraySegment\u003cbyte\u003e\u003e, DisposableValue\u003cArraySegment\u003cbyte\u003e\u003e\u003e ToFrameClientSubject(this TcpClient client, BufferManager bufferManager, CancellationToken token)\n{\n    return Subject.Create(client.ToFrameClientObserver(token), client.ToFrameClientObservable(bufferManager));\n}\n\npublic static IObservable\u003cDisposableValue\u003cArraySegment\u003cbyte\u003e\u003e\u003e ToFrameClientObservable(this TcpClient client, BufferManager bufferManager)\n{\n    return client.GetStream().ToFrameStreamObservable(bufferManager);\n}\n\npublic static IObserver\u003cDisposableValue\u003cArraySegment\u003cbyte\u003e\u003e\u003e ToFrameClientObserver(this TcpClient client, CancellationToken token)\n{\n    return client.GetStream().ToFrameStreamObserver(token);\n}\n```\n\nThe observer is straightforward.\n\n```cs\npublic static IObserver\u003cDisposableValue\u003cArraySegment\u003cbyte\u003e\u003e\u003e ToFrameStreamObserver(this Stream stream, CancellationToken token)\n{\n    return Observer.Create\u003cDisposableValue\u003cArraySegment\u003cbyte\u003e\u003e\u003e(async disposableBuffer =\u003e\n    {\n        var headerBuffer = BitConverter.GetBytes(disposableBuffer.Value.Count);\n        await stream.WriteAsync(headerBuffer, 0, headerBuffer.Length, token);\n        await stream.WriteAsync(disposableBuffer.Value.Array, 0, disposableBuffer.Value.Count, token);\n    });\n}\n```\n\nWe use the [`BitConverter`](https://msdn.microsoft.com/library/system.bitconverter.aspx) to turn the length into a byte stream and send it as\nthe first packet. Finally the byte array is sent.\n\nThe observable requires a helper method to ensure all the required bytes are read.\n\n```cs\npublic static async Task\u003cint\u003e ReadBytesCompletelyAsync(this Stream stream, byte[] buf, int length, CancellationToken token)\n{\n    var read = 0;\n    while (read \u003c length)\n    {\n        var remaining = length - read;\n        var bytes = await stream.ReadAsync(buf, read, remaining, token);\n        if (bytes == 0)\n            return read;\n\n        read += bytes;\n    }\n    return read;\n}\n```\n\nWe need to handle the case where no bytes are returned because the socket is\nclosed. We could throw an exception, but I prefer not to use exceptions to\ncontrol logic, so I return the actual length read.\n\nFinally the frame stream observable.\n\n```cs\npublic static IObservable\u003cDisposableValue\u003cArraySegment\u003cbyte\u003e\u003e\u003e ToFrameStreamObservable(this Stream stream, BufferManager bufferManager)\n{\n    return Observable.Create\u003cDisposableValue\u003cArraySegment\u003cbyte\u003e\u003e\u003e(async (observer, token) =\u003e\n    {\n        var headerBuffer = new byte[sizeof(int)];\n\n        try\n        {\n            while (!token.IsCancellationRequested)\n            {\n                if (await stream.ReadBytesCompletelyAsync(headerBuffer, headerBuffer.Length, token) != headerBuffer.Length)\n                    break;\n                var length = BitConverter.ToInt32(headerBuffer, 0);\n\n                var buffer = bufferManager.TakeBuffer(length);\n                if (await stream.ReadBytesCompletelyAsync(buffer, length, token) != length)\n                    break;\n\n                observer.OnNext(DisposableValue.Create(new ArraySegment\u003cbyte\u003e(buffer, 0, length), Disposable.Create(() =\u003e bufferManager.ReturnBuffer(buffer))));\n            }\n\n            observer.OnCompleted();\n        }\n        catch (Exception error)\n        {\n            observer.OnError(error);\n        }\n    });\n}\n```\n\nI choose not to use the buffer manager to allocate the header buffer as it is\nonly four bytes. We check the actual number of bytes read to detect closed\nsockets, then decode the length with [`BitConverter`](https://msdn.microsoft.com/library/system.bitconverter.aspx).\n\nOnce the length of the content is known we use \n[`BufferManager`](https://msdn.microsoft.com/library/system.servicemodel.channels.buffermanager.aspx) to provide the byte array.\nThe disposable buffer is primed to return the buffer when `Dispose` is called.\n\nThe following example shows how the buffer is finally disposed by the echo\nclient.\n\n```cs\nvar observerDisposable =\n    ToFrameClientObserver(client, bufferManager)\n        .ObserveOn(TaskPoolScheduler.Default)\n        .Subscribe(\n            disposableBuffer =\u003e\n            {\n                Console.WriteLine(\"Read: \" + Encoding.UTF8.GetString(disposableBuffer.Value.Array, 0, disposableBuffer.Value.Count));\n                disposableBuffer.Dispose();\n            },\n            error =\u003e Console.WriteLine(\"Error: \" + error.Message),\n            () =\u003e Console.WriteLine(\"OnCompleted: FrameReceiver\"));\n```\n\n### RxSocketStream\n\nThis is almost as trivial as RxTcp as it uses the `Stream` based asynchronous\nmethods for reading and writing. However it does need to implement the\nasynchronous task pattern for listen and connect.\n\n```cs\npublic static async Task\u003cSocket\u003e AcceptAsync(this Socket socket)\n{\n    return await Task\u003cSocket\u003e.Factory.FromAsync(socket.BeginAccept, socket.EndAccept, null);\n}\n\npublic static async Task ConnectAsync(this Socket socket, IPEndPoint endpoint)\n{\n    await Task.Factory.FromAsync((callback, state) =\u003e socket.BeginConnect(endpoint, callback, state), ias =\u003e socket.EndConnect(ias), null);\n}\n```\n\nFor some reason this does not work if the `EndXXX` call is a method group.\n\n### RxSocket\n\nThis follows on from RxSocketStream by using asynchronous patterns for the\nsending and receiving. I have added a parameter for [`SocketFlags`](https://msdn.microsoft.com/library/system.net.sockets.socketflags.aspx). We should\nbe able to send and receive out of band, but I have not tried this.\n\n### RxSocketSelect\n\nThis is the most convoluted implementation as it uses [`Socket.Select`](https://msdn.microsoft.com/en-us/library/system.net.sockets.socket.select(v=vs.110).aspx) to\nprovide the asynchronous behaviour. I implemented this as a challenge! but also\nto find out what the difference in performance was on Unix based systems using\nMono.\n\nThe implementation is fairly complete, but I have not tried the out of band reading.\n\n## Wrap Up\n\nPlease let me know if you find any problems and I'll apply the fixes.\n\nRob","funding_links":[],"categories":[],"sub_categories":[],"project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frob-blackbourn%2Fjetblack.network","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Frob-blackbourn%2Fjetblack.network","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Frob-blackbourn%2Fjetblack.network/lists"}