Browse Source

First commit

HOME 1 year ago
parent
commit
98457ce17b

+ 104 - 0
SimpleWebChat.BlazorWasm/App.razor

@@ -0,0 +1,104 @@
+@inject HttpClient http
+@code {
+    private string nickName;
+    private string nickNameClass => string.IsNullOrWhiteSpace(nickName) ? "border-warning border-4 form-control" : "form-control";
+
+    private string message;
+    private string messageClass => string.IsNullOrWhiteSpace(message) ? "border-warning border-4 form-control" : "form-control";
+
+    private int connState = 0;
+    private const int maxDisplayMessage = 1000;
+    private List<string> displayingMessages = new List<string>(maxDisplayMessage);
+}
+
+<h2>Simple Web Chat</h2>
+
+@if (connState == 0)
+{
+    <div class="input-group mb-3">
+        <InputText class="@nickNameClass" placeholder="Nick name" type="search" onsearch="@Connect" @oninput="NickNameChanged" ValueExpression="()=>nickName"></InputText>
+        <button @onclick="@Connect" class="btn btn-outline-secondary" type="button">Connect</button>
+    </div>
+}
+else if (connState == 1)
+{
+    <span>Conecting...</span>
+}
+else if (connState == 2)
+{
+    <div class="input-group mb-3">
+        <InputText class="@messageClass" placeholder="Message to send" type="search" onsearch="@Send" @oninput="MessageChanged" Value="@message" ValueExpression="()=>message"></InputText>
+        <button @onclick="@Send" class="btn btn-outline-secondary" type="button">Send</button>
+    </div>
+
+    @foreach (var item in displayingMessages)
+    {
+        <pre class="font-monospace" style="white-space:pre-wrap">@item</pre>
+    }
+}
+else
+{
+    <span>Error</span>
+}
+
+@code {
+    private System.Net.WebSockets.ClientWebSocket sck;
+
+    private void NickNameChanged(ChangeEventArgs e)
+    {
+        nickName = e.Value.ToString().Trim();
+        StateHasChanged();
+    }
+
+    private void MessageChanged(ChangeEventArgs e)
+    {
+        message = e.Value.ToString().Trim();
+        StateHasChanged();
+    }
+
+
+    private async Task Connect()
+    {
+        if (string.IsNullOrEmpty(nickName)) return;
+
+        connState = 1;
+        StateHasChanged();
+
+        try
+        {
+            sck = new System.Net.WebSockets.ClientWebSocket();
+            sck.Options.AddSubProtocol("swcp");
+
+            var b = http.BaseAddress;
+
+            await sck.ConnectAsync(new Uri($"{(b.Scheme == "https" ? "wss" : "ws")}://{b.Host}:{b.Port}/connect"), default);
+
+            connState = 2;
+            StateHasChanged();
+
+            var buf = System.Text.Encoding.UTF8.GetBytes(nickName);
+            await sck.SendAsync(buf, System.Net.WebSockets.WebSocketMessageType.Text, true, default);
+            while (true)
+            {
+                buf = new Byte[1024];
+                var r = await sck.ReceiveAsync(buf, default);
+                displayingMessages.Insert(0, System.Text.Encoding.UTF8.GetString(buf, 0, r.Count));
+                while (displayingMessages.Count >= maxDisplayMessage) displayingMessages.RemoveAt(maxDisplayMessage - 1);
+                StateHasChanged();
+            }
+        }
+        catch (Exception)
+        {
+            connState = -1;
+        }
+    }
+
+    private async Task Send()
+    {
+        if (string.IsNullOrEmpty(message)) return;
+        var msg = $"{nickName}: {message}";
+        await sck.SendAsync(System.Text.Encoding.UTF8.GetBytes(msg), System.Net.WebSockets.WebSocketMessageType.Text, true, default);
+        message = "";
+        StateHasChanged();
+    }
+}

+ 24 - 0
SimpleWebChat.BlazorWasm/Program.cs

@@ -0,0 +1,24 @@
+using Microsoft.AspNetCore.Components.Web;
+using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
+using SimpleWebChat.BlazorWasm;
+
+var builder = WebAssemblyHostBuilder.CreateDefault(args);
+builder.RootComponents.Add<App>("#app");
+builder.RootComponents.Add<HeadOutlet>("head::after");
+
+builder.Services.AddScoped(sp =>
+{
+    var http = new HttpClient();
+
+#if DEBUG
+    http.BaseAddress = new Uri("http://swc-server");
+#else
+    var NavManager = sp.GetService<Microsoft.AspNetCore.Components.NavigationManager>();
+    var ba = new Uri($"{NavManager.ToAbsoluteUri("/").GetLeftPart(UriPartial.Scheme | UriPartial.Authority)}");
+    http.BaseAddress = ba;
+#endif
+
+    return http;
+});
+
+await builder.Build().RunAsync();

+ 39 - 0
SimpleWebChat.BlazorWasm/Properties/launchSettings.json

@@ -0,0 +1,39 @@
+{
+  "iisSettings": {
+    "windowsAuthentication": false,
+    "anonymousAuthentication": true,
+    "iisExpress": {
+      "applicationUrl": "http://localhost:31374",
+      "sslPort": 0
+    }
+  },
+  "profiles": {
+    "SimpleWebChat.BlazorWasm": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "launchBrowser": true,
+      "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
+      "applicationUrl": "http://localhost:5230",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    },
+    "HotLoad": {
+      "commandName": "Executable",
+      "executablePath": "dotnet",
+      "commandLineArgs": "watch",
+      "workingDirectory": ".",
+      "environmentVariables": {
+        "DOTNET_WATCH_SUPPRESS_LAUNCH_BROWSER": "1"
+      }
+    },
+    "IIS Express": {
+      "commandName": "IISExpress",
+      "launchBrowser": true,
+      "inspectUri": "{wsProtocol}://{url.hostname}:{url.port}/_framework/debug/ws-proxy?browser={browserInspectUri}",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    }
+  }
+}

+ 14 - 0
SimpleWebChat.BlazorWasm/SimpleWebChat.BlazorWasm.csproj

@@ -0,0 +1,14 @@
+<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly">
+
+  <PropertyGroup>
+    <TargetFramework>net7.0</TargetFramework>
+    <Nullable>enable</Nullable>
+    <ImplicitUsings>enable</ImplicitUsings>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="7.0.0-preview.4.22251.1" />
+    <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="7.0.0-preview.4.22251.1" PrivateAssets="all" />
+  </ItemGroup>
+
+</Project>

+ 9 - 0
SimpleWebChat.BlazorWasm/_Imports.razor

@@ -0,0 +1,9 @@
+@using System.Net.Http
+@using System.Net.Http.Json
+@using Microsoft.AspNetCore.Components.Forms
+@using Microsoft.AspNetCore.Components.Routing
+@using Microsoft.AspNetCore.Components.Web
+@using Microsoft.AspNetCore.Components.Web.Virtualization
+@using Microsoft.AspNetCore.Components.WebAssembly.Http
+@using Microsoft.JSInterop
+@using SimpleWebChat.BlazorWasm

BIN
SimpleWebChat.BlazorWasm/wwwroot/blazor-192.png


BIN
SimpleWebChat.BlazorWasm/wwwroot/favicon.ico


+ 56 - 0
SimpleWebChat.BlazorWasm/wwwroot/index.html

@@ -0,0 +1,56 @@
+<!DOCTYPE html>
+<html lang="en">
+
+<head>
+    <meta charset="utf-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
+    <link rel="icon" type="image/x-icon" href="favicon.ico">
+    <title>Blazor WASM UI</title>
+
+    <base href="/" />
+
+    <link href="lib/bootstrap/default/bootstrap.min.css" rel="stylesheet" />
+    <link href="lib/bootstrap-icons-1.9.1/bootstrap-icons.css" rel="stylesheet" />
+
+    <link href="swc.css" rel="stylesheet" />
+</head>
+
+<body>
+    <div id="app">
+        <div class="loader text-center" id="loading">
+            <link href="loading.css" rel="stylesheet" />
+            <div class="logo">
+                <div class="one common"></div>
+                <div class="two common"></div>
+                <div class="three common"></div>
+                <div class="four common"></div>
+                <div class="five common"></div>
+                <div class="six common"></div>
+                <div class="seven common"></div>
+                <div class="eight common"></div>
+            </div>
+            <div class="intro">
+                <img src="blazor-192.png" />
+                <span>精彩即将呈现</span>
+            </div>
+            <div class="bar">
+                <div class="progress"></div>
+            </div>
+        </div>
+    </div>
+
+    <div id="blazor-error-ui">
+        <environment include="Staging,Production">
+            An error has occurred. This application may no longer respond until reloaded.
+        </environment>
+        <environment include="Development">
+            An unhandled exception has occurred. See browser dev tools for details.
+        </environment>
+        <a href="" class="reload">Reload</a>
+        <a class="dismiss"><i class="fa fa-times"></i></a>
+    </div>
+
+    <script src="_framework/blazor.webassembly.js"></script>
+    <script src="lib/bootstrap/bootstrap.bundle.min.js"></script>
+</body>
+</html>

File diff suppressed because it is too large
+ 7 - 0
SimpleWebChat.BlazorWasm/wwwroot/lib/bootstrap/bootstrap.bundle.min.js


File diff suppressed because it is too large
+ 7 - 0
SimpleWebChat.BlazorWasm/wwwroot/lib/bootstrap/default/bootstrap.min.css


+ 397 - 0
SimpleWebChat.BlazorWasm/wwwroot/loading.css

@@ -0,0 +1,397 @@
+.loader {
+    position: fixed;
+    top: 0;
+    left: 0;
+    right: 0;
+    bottom: 0;
+    background-color: #161B29;
+    transition: opacity .5s linear;
+    z-index: 2050;
+    display: flex;
+    flex-flow: column;
+}
+
+    .loader.is-done {
+        opacity: 0;
+    }
+
+    .loader .logo {
+        width: 200px;
+        height: 200px;
+        position: relative;
+        margin: 0 auto;
+    }
+
+@keyframes logo {
+    0%, 100% {
+        box-shadow: 1px 1px 25px 10px rgba(146, 148, 248, 0.4);
+    }
+
+    50% {
+        box-shadow: none;
+    }
+}
+
+.loader .intro {
+    width: 250px;
+    color: #fff;
+    font-size: 1.5rem;
+    text-align: center;
+    margin: 3rem auto;
+    display: inline-flex;
+    justify-content: center;
+    align-items: center;
+    padding: 0.5rem 1rem;
+    position: relative;
+    overflow: hidden;
+    border: 1px solid rgb(146, 148, 248);
+    animation: intro 3s linear infinite
+}
+
+@keyframes intro {
+    0%, 100% {
+        box-shadow: 0px 0px 25px 10px rgba(146, 148, 248, 0.4);
+    }
+
+    40%, 60% {
+        box-shadow: 0px 0px 25px 0px rgba(146, 148, 248, 0.4);
+    }
+}
+
+.loader .intro:before {
+    content: "";
+    position: absolute;
+    top: 0;
+    left: -100%;
+    width: 100%;
+    height: 100%;
+    background: linear-gradient( 120deg, transparent, rgba(146, 148, 248, 0.4), transparent );
+    animation: flash 2.5s linear infinite;
+}
+
+@keyframes flash {
+    0%, 100% {
+    }
+
+    10%, 90% {
+        left: 100%;
+    }
+}
+
+.loader .intro img {
+    border-radius: 3px;
+    width: 40px;
+    margin-right: 1rem;
+}
+
+.loader .intro span {
+    color: #fff;
+    animation: title 3s linear infinite;
+}
+
+@keyframes title {
+    0%, 100% {
+        color: #fff;
+    }
+
+    60% {
+        color: #666;
+    }
+}
+
+.loader .bar {
+    width: 50%;
+    height: 4px;
+    border-radius: 2px;
+    margin: auto;
+    background: #E645D0;
+}
+
+    .loader .bar .progress {
+        width: 0%;
+        height: 4px;
+        margin: auto;
+        background: #17E1E6;
+    }
+
+.loader .logo {
+    animation: logo 5s linear infinite;
+    -moz-animation: logo 5s linear infinite;
+    /* Firefox */
+    -webkit-animation: logo 5s linear infinite;
+    /* Safari and Chrome */
+    -o-animation: logo 5s linear infinite;
+    /* Opera */
+}
+
+@keyframes logo {
+    from {
+        transform: rotate(0deg);
+    }
+
+    to {
+        transform: rotate(-360deg);
+    }
+}
+
+.loader .progress {
+    animation: progress 12s linear;
+    -moz-animation: progress 12s linear;
+    /* Firefox */
+    -webkit-animation: progress 12s linear;
+    /* Safari and Chrome */
+    -o-animation: progress 12s linear;
+    /* Opera */
+    animation: progress 12s linear infinite;
+}
+
+@keyframes progress {
+    0%, 100% {
+        width: 0%;
+        background-color: #17e1e6;
+    }
+
+    50% {
+        width: 100%;
+        background-color: #28a745;
+    }
+}
+
+.loader .common {
+    height: 5vw;
+    max-height: 100%;
+    overflow: auto;
+    width: 2vw;
+    margin: auto;
+    max-width: 100%;
+    position: absolute;
+    border-radius: 0vw 10vw 0vw 10vw;
+    box-shadow: inset 0vw 0vw 0vw .1vw #E645D0, 0vw 0vw 1.5vw 0vw #E645D0;
+}
+
+.loader .one {
+    transform: rotate(45deg);
+    left: 0;
+    right: 0;
+    top: 0;
+    bottom: 7.5vw;
+}
+
+.loader .two {
+    transform: rotate(90deg);
+    left: 5.5vw;
+    right: 0;
+    top: 0;
+    bottom: 5.5vw;
+}
+
+.loader .three {
+    transform: rotate(135deg);
+    left: 7.5vw;
+    right: 0;
+    top: 0;
+    bottom: 0;
+}
+
+.loader .four {
+    transform: rotate(180deg);
+    left: 5.5vw;
+    right: 0;
+    top: 5.5vw;
+    bottom: 0;
+}
+
+.loader .five {
+    transform: rotate(225deg);
+    left: 0;
+    right: 0;
+    top: 7.5vw;
+    bottom: 0;
+}
+
+.loader .six {
+    transform: rotate(270deg);
+    left: 0;
+    right: 5.5vw;
+    top: 5.5vw;
+    bottom: 0;
+}
+
+.loader .seven {
+    transform: rotate(315deg);
+    left: 0;
+    right: 7.5vw;
+    top: 0;
+    bottom: 0;
+}
+
+.loader .eight {
+    transform: rotate(360deg);
+    left: 0;
+    right: 5.5vw;
+    top: 0;
+    bottom: 5.5vw;
+}
+
+.loader .one {
+    animation: one 1s ease infinite;
+    -moz-animation: one 1s ease infinite;
+    /* Firefox */
+    -webkit-animation: one 1s ease infinite;
+    /* Safari and Chrome */
+    -o-animation: one 1s ease infinite;
+    /* Opera */
+}
+
+@keyframes one {
+    0%, 100% {
+    }
+
+    50% {
+        box-shadow: inset 0vw 0vw 0vw .1vw #17E1E6, 0vw 0vw 1.5vw 0vw #17E1E6;
+    }
+}
+
+.loader .two {
+    animation: two 1s .125s ease infinite;
+    -moz-animation: two 1s .125s ease infinite;
+    /* Firefox */
+    -webkit-animation: two 1s .125s ease infinite;
+    /* Safari and Chrome */
+    -o-animation: two 1s .125s ease infinite;
+    /* Opera */
+}
+
+@keyframes two {
+    0%, 100% {
+    }
+
+    50% {
+        box-shadow: inset 0vw 0vw 0vw .1vw #17E1E6, 0vw 0vw 1.5vw 0vw #17E1E6;
+    }
+}
+
+.loader .three {
+    animation: three 1s .25s ease infinite;
+    -moz-animation: three 1s .25s ease infinite;
+    /* Firefox */
+    -webkit-animation: three 1s .25s ease infinite;
+    /* Safari and Chrome */
+    -o-animation: three 1s .25s ease infinite;
+    /* Opera */
+}
+
+@keyframes three {
+    0%, 100% {
+    }
+
+    50% {
+        background:;
+        box-shadow: inset 0vw 0vw 0vw .1vw #17E1E6, 0vw 0vw 1.5vw 0vw #17E1E6;
+    }
+}
+
+.loader .four {
+    animation: four 1s .375s ease infinite;
+    -moz-animation: four 1s .375s ease infinite;
+    /* Firefox */
+    -webkit-animation: four 1s .375s ease infinite;
+    /* Safari and Chrome */
+    -o-animation: four 1s .375s ease infinite;
+    /* Opera */
+}
+
+@keyframes four {
+    0%, 100% {
+    }
+
+    50% {
+        box-shadow: inset 0vw 0vw 0vw .1vw #17E1E6, 0vw 0vw 1.5vw 0vw #17E1E6;
+    }
+}
+
+.loader .five {
+    animation: five 1s .5s ease infinite;
+    -moz-animation: five 1s .5s ease infinite;
+    /* Firefox */
+    -webkit-animation: five 1s .5s ease infinite;
+    /* Safari and Chrome */
+    -o-animation: five 1s .5s ease infinite;
+    /* Opera */
+}
+
+@keyframes five {
+    0%, 100% {
+    }
+
+    50% {
+        box-shadow: inset 0vw 0vw 0vw .1vw #17E1E6, 0vw 0vw 1.5vw 0vw #17E1E6;
+    }
+}
+
+.loader .six {
+    animation: six 1s .625s ease infinite;
+    -moz-animation: six 1s .625s ease infinite;
+    /* Firefox */
+    -webkit-animation: six 1s .625s ease infinite;
+    /* Safari and Chrome */
+    -o-animation: six 1s .625s ease infinite;
+    /* Opera */
+}
+
+@keyframes six {
+    0%, 100% {
+    }
+
+    50% {
+        box-shadow: inset 0vw 0vw 0vw .1vw #17E1E6, 0vw 0vw 1.5vw 0vw #17E1E6;
+    }
+}
+
+.loader .seven {
+    animation: seven 1s .750s ease infinite;
+    -moz-animation: seven 1s .750s ease infinite;
+    /* Firefox */
+    -webkit-animation: seven 1s .750s ease infinite;
+    /* Safari and Chrome */
+    -o-animation: seven 1s .750s ease infinite;
+    /* Opera */
+}
+
+@keyframes seven {
+    0%, 100% {
+    }
+
+    50% {
+        box-shadow: inset 0vw 0vw 0vw .1vw #17E1E6, 0vw 0vw 1.5vw 0vw #17E1E6;
+    }
+}
+
+.loader .eight {
+    animation: eight 1s .875s ease infinite;
+    -moz-animation: eight 1s .875s ease infinite;
+    /* Firefox */
+    -webkit-animation: eight 1s .875s ease infinite;
+    /* Safari and Chrome */
+    -o-animation: eight 1s .875s ease infinite;
+    /* Opera */
+}
+
+@keyframes eight {
+    0%, 100% {
+    }
+
+    50% {
+        box-shadow: inset 0vw 0vw 0vw .1vw #17E1E6, 0vw 0vw 1.5vw 0vw #17E1E6;
+    }
+}
+
+@media (min-width: 768px) {
+    .loader {
+        padding-top: 8rem;
+    }
+
+        .loader .intro {
+            margin-top: 6rem;
+        }
+}

+ 17 - 0
SimpleWebChat.BlazorWasm/wwwroot/swc.css

@@ -0,0 +1,17 @@
+#blazor-error-ui {
+    background: lightyellow;
+    bottom: 0;
+    box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
+    display: none;
+    left: 0;
+    padding: 0.6rem 1.25rem 0.7rem 1.25rem;
+    position: fixed;
+    width: 100%;
+    z-index: 1080;
+}
+    #blazor-error-ui .dismiss {
+        cursor: pointer;
+        position: absolute;
+        right: 0.75rem;
+        top: 0.5rem;
+    }

+ 16 - 0
SimpleWebChat.ConHost/Configs/ConfigFile.cs

@@ -0,0 +1,16 @@
+using Newtonsoft.Json;
+
+internal class ConfigFile
+{
+    static ConfigFile() => Reload();
+
+    public static void Reload() => Instance = JsonConvert.DeserializeObject<ConfigFile>(File.ReadAllText(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "config.json")));
+
+    public static ConfigFile Instance { get; private set; }
+
+    public string ListenPrefix { get; set; }
+    public IReadOnlyDictionary<string, ModuleEntry> Modules { get; set; }
+    public string Title { get; set; }
+    public string AdminPassword { get; set; }
+    public int HistoryMessageLength { get; set; }
+}

+ 10 - 0
SimpleWebChat.ConHost/Configs/ModuleEntry.cs

@@ -0,0 +1,10 @@
+public class ModuleEntry
+{
+    public bool IsDefault { get; set; }
+    public string DisplayText { get; set; }
+    public string Path { get; set; }
+
+    public bool EnableFallbackRoute { get; set; }
+    public string DefaultDocument { get; set; }
+    public string HtmlBaseReplace { get; set; }
+}

+ 423 - 0
SimpleWebChat.ConHost/HostProgram.cs

@@ -0,0 +1,423 @@
+using System.Collections.Concurrent;
+using System.IO.Compression;
+using System.Net;
+using System.Net.WebSockets;
+using System.Runtime.CompilerServices;
+using System.Text;
+
+internal static class HostProgram
+{
+    private static readonly ConcurrentDictionary<string, LoadedModule> Modules = new();
+    private static readonly ConcurrentDictionary<int, WebSocket> Sessions = new();
+
+    private static LoadedModule _defaultModule;
+    private static List<byte[]> _historyMessage = new();
+
+    private static bool _isLoading;
+
+    private static bool _isRunning;
+    private static DateTime _lastRequestAccepted;
+    private static int _requestIdStore = 0;
+
+    private static void Main(string[] args)
+    {
+        Console.WriteLine("Starting...");
+
+        var tWorker = new Thread(Working);
+        _isRunning = true;
+        tWorker.Start();
+
+        Task.Run(ReloadConfig);
+
+        Console.WriteLine("Press ENTER to Stop.");
+        Console.ReadLine();
+
+        Console.WriteLine("Shutting down...");
+        _isRunning = false;
+        tWorker.Join();
+
+        Console.WriteLine("Stopped.");
+
+        Console.WriteLine();
+        Console.Write("Press ENTER to Exit.");
+        Console.ReadLine();
+    }
+
+    private static void ReloadConfig()
+    {
+        if (_isLoading)
+        {
+            Console.WriteLine("Still loading, SKIP");
+            return;
+        }
+
+        _isLoading = true;
+
+        try
+        {
+            ConfigFile.Reload();
+            ReloadModulesInternal();
+        }
+        catch (Exception e)
+        {
+            Console.WriteLine($"Load error: {e}");
+        }
+
+        _isLoading = false;
+    }
+
+    private static void ReloadModulesInternal()
+    {
+        Modules.Clear();
+        _defaultModule = null;
+        if (ConfigFile.Instance.Modules?.Any() == true)
+        {
+            foreach (var modEnt in ConfigFile.Instance.Modules)
+            {
+                Console.WriteLine($"Loading module `{modEnt.Value.DisplayText}'...");
+                var module = new LoadedModule
+                {
+                    VirtualPath = modEnt.Key,
+                    DisplayText = modEnt.Value.DisplayText,
+                    DefaultDocument = modEnt.Value.DefaultDocument,
+                    EnableFallbackRoute = modEnt.Value.EnableFallbackRoute,
+                    HtmlBaseReplace = modEnt.Value.HtmlBaseReplace,
+                    Files = new Dictionary<string, byte[]>()
+                };
+
+                if (Directory.Exists(modEnt.Value.Path))
+                {
+                    //load by fs
+                    var files = Directory.GetFiles(modEnt.Value.Path, "*", System.IO.SearchOption.AllDirectories);
+                    foreach (var item in files)
+                    {
+                        var k = item.Substring(modEnt.Value.Path.Length + 1).Replace("\\", "/").ToLower();
+                        module.Files[k] = File.ReadAllBytes(item);
+                    }
+                }
+                else if (File.Exists(modEnt.Value.Path))
+                {
+                    //load by package
+                    using var arc = SharpCompress.Archives.ArchiveFactory.Open(modEnt.Value.Path);
+                    foreach (var ent in arc.Entries.Where(p => p.IsDirectory == false))
+                    {
+                        var buf = new byte[ent.Size];
+                        using var s = ent.OpenEntryStream();
+                        var r = s.Read(buf, 0, buf.Length);
+                        module.Files[ent.Key.ToLower()] = buf;
+                    }
+                }
+                else
+                {
+                    Console.WriteLine("WARN: resource not found");
+                    continue;
+                }
+
+                if (modEnt.Value.IsDefault && _defaultModule == null) _defaultModule = module;
+                Modules[modEnt.Key] = module;
+                Console.WriteLine($"Module `{modEnt.Value.DisplayText}' loaded.");
+            }
+        }
+    }
+
+    private static void Working()
+    {
+        var listener = new HttpListener();
+        listener.Prefixes.Add(ConfigFile.Instance.ListenPrefix);
+        listener.Start();
+
+        var upTime = DateTime.Now;
+
+        Console.WriteLine($"HTTP Server started, listening on {ConfigFile.Instance.ListenPrefix}");
+
+        listener.BeginGetContext(ContextGet, listener);
+
+        _lastRequestAccepted = DateTime.Now;
+        while (_isRunning)
+        {
+            var timeSpan = DateTime.Now - _lastRequestAccepted;
+            var up = DateTime.Now - upTime;
+            Console.Title =
+                "SimWebCha"
+                + $" UP {up.Days:00}D {up.Hours:00}H {up.Minutes:00}M {up.Seconds:00}S {up.Milliseconds:000}"
+                + $" / "
+                + $" LA {timeSpan.Days:00}D {timeSpan.Hours:00}H {timeSpan.Minutes:00}M {timeSpan.Seconds:00}S {timeSpan.Milliseconds:000}"
+            ;
+
+            Thread.Sleep(1000);
+        }
+
+        listener.Close();
+
+        Thread.Sleep(1000);
+    }
+
+    private static void ContextGet(IAsyncResult ar)
+    {
+        var listener = (HttpListener)ar.AsyncState;
+        HttpListenerContext context;
+
+        try
+        {
+            context = listener.EndGetContext(ar);
+        }
+        catch (Exception e)
+        {
+            Console.WriteLine(e);
+            return;
+        }
+
+        if (_isRunning) listener.BeginGetContext(ContextGet, listener);
+        ProcessRequest(context);
+    }
+
+    private static void ProcessRequest(HttpListenerContext context)
+    {
+        _lastRequestAccepted = DateTime.Now;
+
+        var request = context.Request;
+
+        var currentSessionId = Interlocked.Increment(ref _requestIdStore);
+        Console.WriteLine($"Request #{currentSessionId:00000} from {request.RemoteEndPoint} {request.HttpMethod} {request.RawUrl}");
+
+        try
+        {
+            var requestPath = request.Url.LocalPath.ToLower();
+            var pathParts = (IReadOnlyList<string>)requestPath.Split(new[] { '/' }, StringSplitOptions.RemoveEmptyEntries);
+
+            if (requestPath == "/" && _defaultModule != null)
+            {
+                if (_defaultModule.EnableFallbackRoute) context.Response.Redirect($"/modules/{_defaultModule.VirtualPath}/");
+                else context.Response.Redirect($"/modules/{_defaultModule.VirtualPath}/{_defaultModule.DefaultDocument}");
+            }
+            else if (requestPath == "/connect" && request.IsWebSocketRequest)
+            {
+                var wsc = context.AcceptWebSocketAsync("swcp").Result;
+                Console.WriteLine($"Request #{currentSessionId:00000} WebSocket Session Start");
+                var sck = Sessions[currentSessionId] = wsc.WebSocket;
+
+                var buffer = new byte[1024];
+                try
+                {
+                    var r = sck.ReceiveAsync(buffer, default).Result;
+                    var s = Encoding.UTF8.GetString(buffer, 0, r.Count);
+
+                    byte[][] copy;
+                    lock (_historyMessage) copy = _historyMessage.ToArray();
+
+                    foreach (var item in copy)
+                    {
+                        sck.SendAsync(item, WebSocketMessageType.Text, true, default).Wait();
+                    }
+
+                    BroadCast($"SYS{Environment.NewLine}" +
+                        $"  Session #{currentSessionId:X4}({s}) Connected.{Environment.NewLine}" +
+                        $"  Now number of online session: {Sessions.Count}");
+
+                    while (true)
+                    {
+                        r = sck.ReceiveAsync(buffer, default).Result;
+                        if (r.Count == 0)
+                        {
+                            break;
+                        }
+                        else
+                        {
+                            s = Encoding.UTF8.GetString(buffer, 0, r.Count);
+                            BroadCast($"#{currentSessionId:X4}{Environment.NewLine}  {s}");
+                        }
+                    }
+                }
+                catch (Exception)
+                {
+                    try
+                    {
+                        if (sck.State != WebSocketState.Aborted)
+                        {
+                            sck.CloseAsync(WebSocketCloseStatus.InternalServerError, "Error", default).Wait();
+                        }
+                    }
+                    catch (Exception e)
+                    {
+                        Console.WriteLine(e);
+                    }
+                }
+            }
+            else if (requestPath.StartsWith("/modules/") && pathParts.Count > 1)
+            {
+                var moduleKey = pathParts[1];
+                if (Modules.TryGetValue(moduleKey, out var module))
+                {
+                    var entPath = string.Join("/", pathParts.Skip(2));
+
+                    void Output(byte[] bin)
+                    {
+                        if (entPath.ToLower().EndsWith(".js")) context.Response.ContentType = "application/javascript";
+                        else if (module.HtmlBaseReplace != null && entPath.ToLower().EndsWith(".html"))
+                        {
+                            //base replace
+                            var html = Encoding.UTF8.GetString(bin);
+                            var r = html.Replace(module.HtmlBaseReplace, $"<base href=\"/modules/{moduleKey}/\" />");
+                            bin = Encoding.UTF8.GetBytes(r);
+                        }
+
+                        context.Response.OutputStream.Write(bin, 0, bin.Length);
+                    }
+
+                    if (module.Files.TryGetValue(entPath, out var bin))
+                    {
+                        Output(bin);
+                    }
+                    else if (module.EnableFallbackRoute && module.Files.TryGetValue(module.DefaultDocument, out var defBin))
+                    {
+                        entPath = module.DefaultDocument;
+                        Output(defBin);
+                    }
+                    else context.Response.StatusCode = 404;
+                }
+                else context.Response.StatusCode = 404;
+            }
+            else if (requestPath == "/admin/" && false == request.QueryString.AllKeys.Contains("action"))
+            {
+                var sb = new StringBuilder();
+                sb.Append("<!DOCTYPE html><html lang=\"zh-cn\"><meta charset=\"UTF-8\">");
+                sb.Append($"<title> Admin - {ConfigFile.Instance.Title} </title>");
+                sb.Append("<body bgColor=skyBlue>");
+                sb.Append($"<h3>Admin</h3>");
+                sb.Append("<div><a href=/>Back to home</a></div>");
+                sb.Append($"<form method=GET>");
+                sb.Append($"Password: <input type=password name=pass />");
+                sb.Append($"<br/>");
+                sb.Append($"Operation: ");
+                sb.Append($"<input type=submit name=action value=Reload /> ");
+                sb.Append($"</form>");
+
+                context.WriteTextUtf8(sb.ToString());
+            }
+            else if (requestPath == "/admin/" && request.QueryString["action"] == "Reload" && request.QueryString["pass"] == ConfigFile.Instance.AdminPassword)
+            {
+                Task.Run(ReloadConfig);
+                context.Response.Redirect("/");
+            }
+            else if (requestPath == "/admin/")
+            {
+                context.Response.Redirect("/");
+            }
+            else
+            {
+                context.Response.StatusCode = 404;
+            }
+        }
+        catch (Exception e)
+        {
+            Console.WriteLine(e);
+            try
+            {
+                context.Response.StatusCode = 500;
+            }
+            catch (Exception exception)
+            {
+                Console.WriteLine(exception);
+            }
+        }
+        finally
+        {
+            try
+            {
+                if (request.IsWebSocketRequest)
+                {
+                    Sessions.Remove(currentSessionId, out _);
+                    BroadCast($"SYS{Environment.NewLine}" +
+                        $"  Session #{currentSessionId:X4} Disconnected.{Environment.NewLine}" +
+                        $"  Now number of online session: {Sessions.Count}");
+                }
+
+                Console.WriteLine($"Request #{currentSessionId:0000} ends with status code: {context.Response.StatusCode}");
+            }
+            catch (Exception e)
+            {
+                Console.WriteLine(e);
+            }
+
+            try
+            {
+                context.Response.Close();
+            }
+            catch (Exception e)
+            {
+                Console.WriteLine(e);
+            }
+        }
+    }
+
+    private static void BroadCast(string content)
+    {
+        var now = DateTime.Now;
+        string text = $"{now} {content}";
+        var buf = Encoding.UTF8.GetBytes(text);
+
+        lock (_historyMessage)
+        {
+            _historyMessage.Add(buf);
+            while (_historyMessage.Count >= ConfigFile.Instance.HistoryMessageLength)
+            {
+                _historyMessage.RemoveAt(0);
+            }
+        }
+
+        if (Sessions.Count == 0) return;
+        foreach (var item in Sessions)
+        {
+            try
+            {
+                if (item.Value.State == WebSocketState.Open)
+                {
+                    item.Value.SendAsync(buf, WebSocketMessageType.Text, true, default);
+                }
+                else
+                {
+                    Sessions.Remove(item.Key, out _);
+                    BroadCast($"SYS{Environment.NewLine}" +
+                        $"  Session #{item.Key:X4} Disconnected.{Environment.NewLine}" +
+                        $"  Now number of online session: {Sessions.Count}");
+                }
+            }
+            catch (Exception e)
+            {
+                Console.WriteLine(e);
+            }
+        }
+    }
+
+    public static void WriteTextUtf8(this HttpListenerContext context, string content, string contentType = "text/html")
+    {
+        var bytes = Encoding.UTF8.GetBytes(content);
+        context.Response.ContentEncoding = Encoding.UTF8;
+        context.Response.ContentType = contentType;
+
+        if (true == context.Request.Headers["Accept-Encoding"]?.Contains("gzip"))
+        {
+            context.Response.AddHeader("Content-Encoding", "gzip");
+
+            var memoryStream = new MemoryStream(bytes);
+            var gZipStream = new GZipStream(context.Response.OutputStream, CompressionMode.Compress, false);
+            memoryStream.CopyTo(gZipStream);
+            gZipStream.Flush();
+        }
+        else
+        {
+            context.Response.OutputStream.Write(bytes);
+        }
+    }
+}
+
+internal class LoadedModule
+{
+    public string VirtualPath { get; set; }
+    public string DisplayText { get; set; }
+    public string DefaultDocument { get; set; }
+
+    public Dictionary<string, byte[]> Files { get; set; }
+    public bool EnableFallbackRoute { get; set; }
+    public string HtmlBaseReplace { get; set; }
+}

+ 21 - 0
SimpleWebChat.ConHost/SimpleWebChat.ConHost.csproj

@@ -0,0 +1,21 @@
+<Project Sdk="Microsoft.NET.Sdk">
+
+  <PropertyGroup>
+    <OutputType>Exe</OutputType>
+    <TargetFramework>net7.0</TargetFramework>
+    <ImplicitUsings>enable</ImplicitUsings>
+    <Nullable>enable</Nullable>
+  </PropertyGroup>
+
+  <ItemGroup>
+    <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
+    <PackageReference Include="SharpCompress" Version="0.32.2" />
+  </ItemGroup>
+
+  <ItemGroup>
+    <None Update="config.json">
+      <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+    </None>
+  </ItemGroup>
+
+</Project>

+ 16 - 0
SimpleWebChat.ConHost/config.json

@@ -0,0 +1,16 @@
+{
+  "ListenPrefix": "http://swc-server/",
+  "HistoryMessageLength": 100,
+  "Title": "Simple Web Chat",
+  "AdminPassword": "",
+  "Modules": {
+    "blazor": {
+      "IsDefault": true,
+      "DisplayText": "Blazor WASM UI",
+      "Path": "z:/bwu",
+      "DefaultDocument": "index.html",
+      "EnableFallbackRoute": true,
+      "HtmlBaseReplace": "<base href=\"/\" />"
+    }
+  }
+}

+ 31 - 0
SimpleWebChat.sln

@@ -0,0 +1,31 @@
+
+Microsoft Visual Studio Solution File, Format Version 12.00
+# Visual Studio Version 17
+VisualStudioVersion = 17.4.32804.182
+MinimumVisualStudioVersion = 10.0.40219.1
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleWebChat.ConHost", "SimpleWebChat.ConHost\SimpleWebChat.ConHost.csproj", "{4AE488F7-E3FD-4339-9C31-2BCC5AF83EDD}"
+EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SimpleWebChat.BlazorWasm", "SimpleWebChat.BlazorWasm\SimpleWebChat.BlazorWasm.csproj", "{EEBDF24C-F814-44B2-A20C-812737AA313B}"
+EndProject
+Global
+	GlobalSection(SolutionConfigurationPlatforms) = preSolution
+		Debug|Any CPU = Debug|Any CPU
+		Release|Any CPU = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(ProjectConfigurationPlatforms) = postSolution
+		{4AE488F7-E3FD-4339-9C31-2BCC5AF83EDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{4AE488F7-E3FD-4339-9C31-2BCC5AF83EDD}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{4AE488F7-E3FD-4339-9C31-2BCC5AF83EDD}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{4AE488F7-E3FD-4339-9C31-2BCC5AF83EDD}.Release|Any CPU.Build.0 = Release|Any CPU
+		{EEBDF24C-F814-44B2-A20C-812737AA313B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+		{EEBDF24C-F814-44B2-A20C-812737AA313B}.Debug|Any CPU.Build.0 = Debug|Any CPU
+		{EEBDF24C-F814-44B2-A20C-812737AA313B}.Release|Any CPU.ActiveCfg = Release|Any CPU
+		{EEBDF24C-F814-44B2-A20C-812737AA313B}.Release|Any CPU.Build.0 = Release|Any CPU
+	EndGlobalSection
+	GlobalSection(SolutionProperties) = preSolution
+		HideSolutionNode = FALSE
+	EndGlobalSection
+	GlobalSection(ExtensibilityGlobals) = postSolution
+		SolutionGuid = {C140867B-A0A0-4D30-A93B-8D18214D5031}
+	EndGlobalSection
+EndGlobal