SimpleW 11.3.2
See the version list below for details.
dotnet add package SimpleW --version 11.3.2
NuGet\Install-Package SimpleW -Version 11.3.2
<PackageReference Include="SimpleW" Version="11.3.2" />
paket add SimpleW --version 11.3.2
#r "nuget: SimpleW, 11.3.2"
// Install SimpleW as a Cake Addin #addin nuget:?package=SimpleW&version=11.3.2 // Install SimpleW as a Cake Tool #tool nuget:?package=SimpleW&version=11.3.2
SimpleW
SimpleW is a Simple Web Server library in NET7 (windows/linux/macos).<br> It brings an easy layer on top of the great NetCoreServer socket server write by chronoxor in pure C#.
Summary
Features
- Routing
- Serve Static Files
- Serve RestAPI (Controller/Medthod + automatic json serialization/deserialization)
- Integrated JWT Authentication
- Websocket
Installation
Using SimpleW nuget package
dotnet add package SimpleW
Note : SimpleW depends Newtonsoft.Json package for json serialization/deserialization.
It will be replaced in futur by the native System.Text.Json
as soon as
some advanced features will be covered (Populate
and streamingContextObject
, see WIP).
Usage
Routes
In SimpleW, all is about routing and there are 2 differents kind of routes :
- statics : for serving statics files (html, js, css, png...)
- dynamics : for serving API (C# code)
Note : Reflection
is only used to list routes, once, before server start.
An expression tree
is built to call method fast without using any T.GetMethod().Invoke()
.
Serve Statics Files
Basic Static Example
To serve statics files with a very few lines of code :
using System;
using System.Net;
using SimpleW;
namespace Sample {
class Program {
static void Main() {
// listen to all IPs port 2015
var server = new SimpleWServer(IPAddress.Any, 2015);
// serve static content located in you folder "C:\www\" to "/" endpoint
server.AddStaticContent(@"C:\www\", "/");
// enable autoindex if not index.html exists in the directory
server.AutoIndex = true;
server.Start();
Console.WriteLine("server started at http://localhost:2015/");
// block console for debug
Console.ReadKey();
}
}
}
Then just point your browser to http://localhost:2015/.
Note : if AutoIndex
is false and the directory does not contain a default document index.html
, an http 404 error will return.
Note : on Windows, the Firewall can block this simple console app even if expose on localhost and port > 1024. You need to allow access else you will not reach the server.
Multiples Directories
SimpleW can handle multiples directories as soon as they are declared under differents endpoints.
using System;
using System.Net;
using SimpleW;
namespace Sample {
class Program {
static void Main() {
// listen to all IPs port 2015
var server = new SimpleWServer(IPAddress.Any, 2015);
// serve directories/endpoints
server.AddStaticContent(@"C:\www\frontend", "/");
server.AddStaticContent(@"C:\www\public", "/public/");
server.Start();
Console.WriteLine("server started at http://localhost:2015/");
// block console for debug
Console.ReadKey();
}
}
}
Options
You can change some settings before server start.
To change the default document
server.DefaultDocument = "maintenance.html";
To add custom mimeTypes
server.AddMimeTypes(".vue", "text/html");
Cache
The AddStaticContent()
cache all directories/files in RAM (default: 1 hour) on server start.<br />
Also, an internal filesystem watcher is maintaining this cache up to date.
It supports realtime file editing even when specific lock/write occurs.
To modify cache duration or filter files
// serve statics files
server.AddStaticContent(
@"C:\www\", // under C:\www or its subdirectories
"/", // to / endpoint
"*.csv", // only CSV files
TimeSpan.FromDays(1) // set cache to 1 day
);
Serve RestAPI
Basic RestAPI Example
As already said, the RestAPI is based on routes.<br />
So juste add a RouteAttribute
to target methods of a Controller
base class.<br />
The return is serialized into json and sent as response to the client.
Use server.AddDynamicContent()
to handle RestAPI.
using System;
using System.Net;
using SimpleW;
namespace Sample {
class Program {
static void Main() {
// listen to all IPs port 2015
var server = new SimpleWServer(IPAddress.Any, 2015);
// find all Controllers class and serve on the "/api/" endpoint
server.AddDynamicContent("/api/");
server.Start();
Console.WriteLine("server started at http://localhost:2015/api/");
// block console for debug
Console.ReadKey();
}
}
// a Controller base class
public class SomeController : Controller {
// use the Route attribute to target a public method
[Route("GET", "test")]
public object SomePublicMethod() {
// the return will be serialized to json
return new {
hello = "world"
};
}
}
}
Then just open your browser to http://localhost:2015/api/test and you will see the { "hello": "world" }
json response.
Note : the controller must not have a constructor.
Return Type
Any return type (object
, List
, Dictionary
, String
...) will be serialized and sent as json to the client.
The following example illustrates differents return types :
using System;
using System.Net;
using SimpleW;
namespace Sample {
class Program {
static void Main() {
// listen to all IPs port 2015
var server = new SimpleWServer(IPAddress.Any, 2015);
// find all Controllers class and serve on the "/api/" endpoint
server.AddDynamicContent("/api/");
server.Start();
Console.WriteLine("server started at http://localhost:2015/api/");
// block console for debug
Console.ReadKey();
}
}
public class TestController : Controller {
[Route("GET", "test1")]
public object Test1() {
// return: { "hello": "world", "date": "2023-10-23T00:00:00+02:00", "result": true }
return new {
hello = "world",
date = new DateTime(2023, 10, 23),
result = true
};
}
[Route("GET", "test2")]
public object Test2() {
// return: ["hello", "world"]
return new string[] { "hello", "world" };
}
}
public class UserController : Controller {
[Route("GET", "users")]
public object Users() {
// return: [{"Email":"user1@localhost","FullName":"user1"},{"Email":"user2@localhost","FullName":"user2"}]
var users = new List<User>() {
new User() { Email = "user1@localhost", FullName = "user1" },
new User() { Email = "user2@localhost", FullName = "user2" },
};
return users;
}
}
// example class
public class User {
// these public properties will be serialized
public string Email { get; set; }
public string FullName { get ;set; }
// private will not be serialized
private bool Enabled = false;
}
}
To see the results, open your browser to :
- http://localhost:2015/api/test1
- http://localhost:2015/api/test2
- http://localhost:2015/api/users
Note : there is no need to specify the exact type the method will return.
Most of the time, object
is enougth and will be passed to a JsonConvert.SerializeObject(object)
.
Return Helpers
In fact, the Controller
class is dealing with an HttpResponse
object which is sent async to the client.<br />
You can manipulate this object with the property Response
.
There are also some useful helpers which facilitate returning specific HttpReponse
:
using System;
using System.Net;
using SimpleW;
namespace Sample {
class Program {
static void Main() {
// listen to all IPs port 2015
var server = new SimpleWServer(IPAddress.Any, 2015);
// find all Controllers class and serve on the "/api/" endpoint
server.AddDynamicContent("/api/");
server.Start();
Console.WriteLine("server started at http://localhost:2015/api/");
// block console for debug
Console.ReadKey();
}
}
public class TestController : Controller {
[Route("GET", "test1")]
public object Test1() {
// the object return will be serialized
// and set as body of the HttpReponse
// and a mimetype json
// with a status code 200
return new { hello = "world" };
}
[Route("GET", "test2")]
public object Test2() {
try {
throw new Exception("test2");
}
catch (Exception ex) {
// set message exception as body of the HttpReponse
// and a mimetype text
// with a status code 500
return MakeInternalServerErrorResponse(ex.Message);
}
}
[Route("GET", "test3")]
public object Test3() {
try {
throw new KeyNotFoundException("test3");
}
catch (Exception ex) {
// set message exception as body of the HttpReponse
// and a mimetype text
// with a status code 404
return MakeNotFoundResponse(ex.Message);
}
}
[Route("GET", "test4")]
public object Test4() {
try {
throw new UnauthorizedAccessException("test4");
}
catch (Exception ex) {
// set message exception as body of the HttpReponse
// and a mimetype text
// with a status code 401
return MakeUnAuthorizedResponse(ex.Message);
}
}
[Route("GET", "test5")]
public object Test5() {
var content = "download text content";
// will force download a file "file.txt" with content
return MakeDownloadResponse(content, "file.txt");
}
}
}
Note : all these helpers support differents types of parameters and options to deal with most of the use cases. Just browse to discover all the possibilities.
Edge Cases of Return
Documentation in progress...
Routing
Each route is a concatenation of :
Prefix
defined byAddDynamicContent()
.Route
attribute on Controller class (if exists).Route
attribute on Method.
Examples
Route
attribute on methods.
using System;
using System.Net;
using SimpleW;
namespace Sample {
class Program {
static void Main() {
var server = new SimpleWServer(IPAddress.Any, 2015);
server.AddDynamicContent("/api/");
server.Start();
Console.ReadKey();
}
}
public class TestController : Controller {
// call on GET http://localhost:2015/api/test/index
[Route("GET", "test/index")]
public object Index() {
return "test index page";
}
// call POST http://localhost:2015/api/test/create
[Route("POST", "test/create")]
public object Create() {
return "test create success";
}
}
}
The same example can be refactored with Route
attribute on controller class.
using System;
using System.Net;
using SimpleW;
namespace Sample {
class Program {
static void Main() {
var server = new SimpleWServer(IPAddress.Any, 2015);
server.AddDynamicContent("/api/");
server.Start();
Console.ReadKey();
}
}
[Route("test/")]
public class TestController : Controller {
// call on GET http://localhost:2015/api/test/index
[Route("GET", "index")]
public object Index() {
return "test index page";
}
// call POST http://localhost:2015/api/test/create
[Route("POST", "create")]
public object Create() {
return "test create success";
}
}
}
You can override a method Route
with the parameter isAbsolutePath: true
.
using System;
using System.Net;
using SimpleW;
namespace Sample {
class Program {
static void Main() {
var server = new SimpleWServer(IPAddress.Any, 2015);
server.AddDynamicContent("/api/");
server.Start();
Console.ReadKey();
}
}
[Route("test/")]
public class TestController : Controller {
// test with http://localhost:2015/api/test/index
[Route("GET", "index")]
public object Index() {
return "test index page";
}
// test with http://localhost:2015/home
[Route("GET", "/home", isAbsolutePath: true)]
public object Home() {
return "home page";
}
// test with POST http://localhost:2015/api/test/create
[Route("POST", "create")]
public object Create() {
return "test create success";
}
// test with POST http://localhost:2015/api/test/delete
// or
// test with POST http://localhost:2015/api/test/remove
[Route("POST", "delete")]
[Route("POST", "remove")]
public object Delete() {
return "test delete success";
}
}
}
Note :
- the
isAbsolutePath
flag will not take the prefix defined in theAddDynamicContent
. - methods can have multiple
Route
Attributes (example above with delete, remove).
Regexp
Route
path support regulars expressions.
using System;
using System.Net;
using SimpleW;
namespace Sample {
class Program {
static void Main() {
var server = new SimpleWServer(IPAddress.Any, 2015);
server.AddDynamicContent("/api/");
server.Start();
Console.ReadKey();
}
}
[Route("test/")]
public class TestController : Controller {
// http://localhost:2015/api/test/index
// or
// http://localhost:2015/api/test/indexes
[Route("GET", "(index|indexes)")]
public object Index() {
return "test index page";
}
}
}
QueryString Parameters
Query String parameters are also supported in a similar way. The library will map query string parameter to the method parameter.
using System;
using System.Net;
using SimpleW;
namespace Sample {
class Program {
static void Main() {
var server = new SimpleWServer(IPAddress.Any, 2015);
server.AddDynamicContent("/api/");
server.Start();
Console.ReadKey();
}
}
public class TestController : Controller {
// test with http://localhost:2015/api/hello
// test with http://localhost:2015/api/hello?name=stratdev
//
// parameter "name" has default value "world"
// so the query string "name" is not mandatory
[Route("GET", "hello")]
public object Hello(string name = "world") {
return $"Hello {name} !";
}
// test with http://localhost:2015/api/hi?name=stratdev
// test with http://localhost:2015/api/hi
//
// parameter "name" has no default value
// so the query string "name" is required
// not providing it will return an HTTP 404 ERROR
[Route("GET", "hi")]
public object Hi(string name) {
return $"Hi {name} !";
}
// test with http://localhost:2015/api/bye?name=stratdev&exit=0
//
// it does not matter if there are others query strings
// than the one declared in the method
[Route("GET", "bye")]
public object Bye(string name) {
return $"Bye {name} !";
}
// test with http://localhost:2015/api/debug?a=bbbb&c=dddd
[Route("GET", "debug")]
public object Debug() {
try {
// get NameValueCollection
var nvc = Route.ParseQueryString(this.Request.Url);
// convert to dictionnary
var querystrings = nvc.AllKeys.ToDictionary(k => k, k => nvc[k]);
return new {
message = $"list query string parameters from {this.Request.Url}",
querystrings
};
}
catch (Exception ex) {
return MakeInternalServerErrorResponse(ex.Message);
}
}
}
}
Notes :
QueryString
are map by name to the parameter method.- Only declared parameters are map.
- When a method has a mandatory parameter (without default value), the route will not match if not provided in the url (return HTTP CODE 404).
Path Parameters
Route
path parameters are also supported in a similar way.
When a {parameter}
is declared in the path, it's possible to set parameter in Route
path and retrieve their value in the method.<br />
The library will map them according to their name.
using System;
using System.Net;
using SimpleW;
namespace Sample {
class Program {
static void Main() {
var server = new SimpleWServer(IPAddress.Any, 2015);
server.AddDynamicContent("/api/");
server.Start();
Console.ReadKey();
}
}
[Route("test/")]
public class TestController : Controller {
// test with http://localhost:2015/api/test/user/stratdev
[Route("GET", "user/{login}")]
public object User(string login) {
return $"Hello {login}";
}
// test with http://localhost:2015/api/test/stratdev/2023
// but
// test with http://localhost:2015/api/test/stratdev/xx will
// return a http code 500 as the "xx" cast to integer
// will thrown an exception
[Route("GET", "user/{login}/{year}")]
public object User(string login, int year) {
return $"Hello {login}, you're {year} year old.";
}
}
}
Note :
- In this example, the value
stratdev
of{login}
parameter will be map tostring login
and the value2023
of{year}
parameter will be map toint year
. - the string value of parameter will be cast to the parameter type. If the cast failed, an HTTP CODE 500 will be return to the client.
- all declared parameters in
Route
path are mandatory.
POST body (application/json) deserialization
You can use the BodyMap()
method for reading POST body and deserialize to an object instance.
Frontend send POST json data
var json_payload = {
id: "c037a13c-5e77-11ec-b466-e33ffd960c3a",
name: "test",
creation: "2021-12-21T15:06:58",
enabled: true
};
axios.post("http://localhost:2015/api/user/save", json_payload);
Backend receive
public class User {
public Guid id;
public string name;
public DateTime creation;
public bool enabled;
}
[Route("POST", "user/save")]
public object Save() {
var user = new User();
// map POST body JSON to object instance
Request.BodyMap(user);
return new {
user
};
}
Note :
- the content-type set by client need to be
application/json
which is the default for axios.
POST body (application/x-www-form-urlencoded) deserialization
You can use the BodyMap()
method for reading POST body and deserialize to an object instance.
Frontend send POST json data
const form_payload = {
id: "c037a13c-5e77-11ec-b466-e33ffd960c3a",
name: "test",
creation: "2021-12-21T15:06:58",
enabled: true
};
const options = {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
// encode payload to format "key=value&key=value.."
data: Object.keys(form_payload)
.map((key) => `${key}=${encodeURIComponent(form_payload[key])}`)
.join('&')
url: "http://localhost:2015/api/user/save",
};
axios(options);
Backend receive
public class User {
public Guid id;
public string name;
public DateTime creation;
public bool enabled;
}
[Route("POST", "user/save")]
public object Save() {
var user = new User();
// map POST body FORM to object instance
Request.BodyMap(user);
return new {
user
};
}
Serialization
Default
The public object method
will be serialized to json using the excellent JsonConvert.SerializeObject()
from Newtonsoft.Json
[Route("GET", "test")]
public object Test() {
return new {
hello = "Hello World !",
current = Datetime.Now,
i = 0,
d = new Dictionary<string, string>() { { "Foo", "Bar" } }
};
}
Requesting to http://localhost:2015/api/test
will result to
{
"hello": "Hello World !",
"current": "2021-12-15T11:44:29.1249399+01:00",
"i": 0,
"d": {"Foo":"Bar"}
}
Request
You can access the Request
property inside any controller.
Responses
You can access the Response
property inside any controller.
3. JWT Authentication
Documentation in progress...
4. Websockets
Documentation in progress...
OpenTelemetry
SimpleW handle an opentelemetry Activity
and publish Event
.
As such, you can subscribe to this source
and ...
See an example which log all request to console (do not use for production). Open browser to http://localhost:2015/api/test and console will show log.
using System;
using System.Net;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using OpenTelemetry;
using OpenTelemetry.Trace;
using SimpleW;
namespace Sample {
class Program {
static void Main() {
// subscribe to all SimpleW events
openTelemetryObserver("SimpleW");
// listen to all IPs port 2015
var server = new SimpleWServer(IPAddress.Any, 2015);
// find all Controllers class and serve on the "/api/" endpoint
server.AddDynamicContent("/api/");
server.Start();
Console.WriteLine("server started at http://localhost:2015/api/");
// block console for debug
Console.ReadKey();
}
static TracerProvider openTelemetryObserver(string source) {
return Sdk.CreateTracerProviderBuilder()
.AddSource(source)
.AddProcessor(new LogProcessor()) // custom log processor
.SetResourceBuilder(
ResourceBuilder
.CreateEmpty()
.AddService(serviceName: "Sample", serviceVersion: "0.1")
).Build();
}
}
// custom log processor
class LogProcessor : BaseProcessor<Activity> {
// write log to console
public override void OnEnd(Activity activity) {
Console.WriteLine($"{activity.GetTagItem("http.request.method")} \"{activity.GetTagItem("url.full")}\" {activity.GetTagItem("http.response.status_code")} {(int)activity.Duration.TotalMilliseconds}ms session-{activity.GetTagItem("session")} {activity.GetTagItem("client.address")} \"{activity.GetTagItem("user_agent.original")}\"");
}
}
public class SomeController : Controller {
[Route("GET", "test")]
public object SomePublicMethod() {
return new {
hello = "world"
};
}
}
}
For production grade, better to use well known solutions.
Uptrace is one of them can be easily integrate thanks to the Uptrace nuget package
See example
using System;
using System.Net;
using System.Diagnostics;
using System.Diagnostics.Metrics;
using OpenTelemetry;
using OpenTelemetry.Trace;
using Uptrace.OpenTelemetry;
using SimpleW;
namespace Sample {
class Program {
static void Main() {
// subscribe to all SimpleW events
openTelemetryObserver("SimpleW");
// listen to all IPs port 2015
var server = new SimpleWServer(IPAddress.Any, 2015);
// find all Controllers class and serve on the "/api/" endpoint
server.AddDynamicContent("/api/");
server.Start();
Console.WriteLine("server started at http://localhost:2015/");
// block console for debug
Console.ReadKey();
}
static TracerProvider openTelemetryObserver(string source) {
return Sdk.CreateTracerProviderBuilder()
.AddSource(source)
// see https://uptrace.dev/get/get-started.html#dsn
.AddUptrace(uptrace_connection_string)
.SetResourceBuilder(
ResourceBuilder
.CreateEmpty()
.AddService(serviceName: "Sample", serviceVersion: "0.1")
).Build();
}
}
public class SomeController : Controller {
[Route("GET", "test")]
public object SomePublicMethod() {
return new {
hello = "world"
};
}
}
}
License
This library is under the MIT License.
Product | Versions Compatible and additional computed target framework versions. |
---|---|
.NET | net7.0 is compatible. net7.0-android was computed. net7.0-ios was computed. net7.0-maccatalyst was computed. net7.0-macos was computed. net7.0-tvos was computed. net7.0-windows was computed. net8.0 was computed. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. |
-
net7.0
- Newtonsoft.Json (>= 13.0.3)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.
Version | Downloads | Last updated | |
---|---|---|---|
12.0.0 | 608 | 5/1/2024 | |
11.9.0 | 591 | 2/15/2024 | |
11.8.0 | 133 | 2/14/2024 | |
11.7.0 | 96 | 2/3/2024 | |
11.6.0 | 103 | 2/2/2024 | |
11.5.0 | 349 | 1/14/2024 | |
11.4.0 | 126 | 1/4/2024 | |
11.3.3 | 160 | 12/24/2023 | |
11.3.2 | 124 | 12/24/2023 | |
11.3.1 | 121 | 12/24/2023 | |
11.3.0 | 198 | 12/15/2023 | |
11.2.0 | 127 | 12/9/2023 | |
11.1.0 | 130 | 11/7/2023 | |
11.0.0 | 124 | 11/1/2023 | |
10.0.0 | 340 | 10/22/2023 | |
9.0.1 | 138 | 10/22/2023 | |
9.0.0 | 122 | 10/21/2023 | |
8.2.0 | 181 | 10/18/2023 | |
8.1.2 | 164 | 10/12/2023 | |
8.1.1 | 122 | 10/12/2023 | |
8.1.0 | 116 | 10/12/2023 | |
8.0.8 | 390 | 8/17/2023 | |
8.0.7 | 139 | 8/16/2023 | |
8.0.6 | 584 | 5/15/2023 | |
8.0.5 | 137 | 5/15/2023 | |
8.0.4 | 462 | 3/30/2023 | |
8.0.3 | 227 | 3/29/2023 | |
8.0.2 | 323 | 3/19/2023 | |
8.0.1 | 410 | 2/16/2023 | |
8.0.0 | 372 | 1/25/2023 | |
7.0.0 | 555 | 12/1/2022 | |
6.2.4 | 333 | 11/30/2022 | |
6.2.3 | 324 | 11/30/2022 | |
6.2.2 | 438 | 11/10/2022 | |
6.2.1 | 465 | 10/20/2022 | |
6.2.0 | 442 | 10/5/2022 | |
6.1.0 | 373 | 10/4/2022 | |
6.0.0 | 383 | 10/2/2022 | |
5.0.0 | 399 | 9/30/2022 | |
4.4.6 | 419 | 9/28/2022 | |
4.4.5 | 405 | 9/28/2022 | |
4.4.4 | 386 | 9/27/2022 | |
4.4.3 | 552 | 8/29/2022 | |
4.4.2 | 410 | 8/24/2022 | |
4.4.1 | 436 | 8/24/2022 | |
4.4.0 | 411 | 8/24/2022 | |
4.3.2 | 523 | 8/13/2022 | |
4.3.1 | 395 | 8/13/2022 | |
4.3.0 | 420 | 8/11/2022 | |
4.2.8 | 507 | 7/29/2022 | |
4.2.7 | 619 | 6/28/2022 | |
4.2.6 | 439 | 6/28/2022 | |
4.2.5 | 446 | 6/14/2022 | |
4.2.4 | 653 | 4/27/2022 | |
4.2.3 | 403 | 4/27/2022 | |
4.2.2 | 590 | 4/7/2022 | |
4.2.1 | 430 | 4/7/2022 | |
4.2.0 | 453 | 4/4/2022 | |
4.1.0 | 496 | 3/22/2022 | |
4.0.0 | 408 | 3/21/2022 | |
3.2.0 | 428 | 3/20/2022 | |
3.1.2 | 509 | 3/10/2022 | |
3.1.1 | 448 | 3/8/2022 | |
3.1.0 | 474 | 2/22/2022 | |
3.0.0 | 455 | 2/21/2022 | |
2.3.0 | 491 | 2/11/2022 | |
2.2.1 | 473 | 2/3/2022 | |
2.1.1 | 452 | 1/31/2022 | |
2.1.0 | 426 | 1/30/2022 | |
2.0.0 | 478 | 1/23/2022 | |
1.1.1 | 402 | 1/3/2022 | |
1.1.0 | 305 | 12/26/2021 | |
1.0.15 | 296 | 12/16/2021 | |
1.0.14 | 293 | 12/16/2021 | |
1.0.13 | 2,409 | 11/26/2021 | |
1.0.12 | 3,888 | 11/24/2021 | |
1.0.11 | 3,748 | 11/24/2021 | |
1.0.10 | 5,481 | 11/24/2021 | |
1.0.9 | 6,557 | 11/23/2021 | |
1.0.8 | 962 | 11/20/2021 | |
1.0.7 | 320 | 11/15/2021 | |
1.0.6 | 297 | 11/15/2021 | |
1.0.5 | 348 | 11/12/2021 | |
1.0.4 | 328 | 11/12/2021 | |
1.0.3 | 374 | 11/12/2021 | |
1.0.2 | 362 | 11/11/2021 | |
1.0.1 | 344 | 11/10/2021 | |
1.0.0 | 405 | 11/10/2021 |