C# で簡易Webサーバを作ってみたよ

# はじめに

* Windows 用の静的コンテンツ専用簡易 Web サーバを作ってみた
* Windows 標準の C# コンパイラを使用しています

https://github.com/yoshi389111/SimpleHttpServer.cs

# 作ったきっかけ

JavaScript で作ったWebアプリが `file://` では動かないケースがいくつかあると思います（`*.js` と同じところにあるファイルを読み込むみたいな処理等）。
そんな時に、簡易的な Web サーバあると便利です。

ちなみに自分が開発している最中ではなく（開発中は大抵の場合、 React なり vue.js なりその他のフレームワークが、開発用の Web サーバを用意してくれるので）、ビルドした結果を誰かに渡して動かしてもらうようなケースを想定しています。

もちろん、Apache や nginx などに入れて動かしてもらえばいいのですが、そこまで大掛かりなことをしたくない（普段は開発とかしていない人に渡して、ちょっと試してもらう場合など）には、Python や Ruby の簡易 Web サーバを使うと思います。PHP や BusyBox にもありますね。

https://qiita.com/yoshi389111/items/76b37c7f54b00f686149

しかし主に Windows 環境の場合に、そのような簡易 Web サーバがなく、かつ外部から exe ファイルをダウンロードするのもはばかれるケースもないわけじゃないと思います。
そんな時のために、Windows 標準の C# コンパイラで簡易 Web サーバを作っておくと便利かと思って作ってみました。
（もし会社のルールなどで、自分でコンパイルした exe も実行禁止、とか、勝手に Web サーバを立ち上げるのは禁止、とかであれば使うのはもちろんダメですが）

類似の記事は多数あるみたいですが、せっかくなので自分で作ってみました。

【類似の記事】

https://atmarkit.itmedia.co.jp/fdotnet/dotnettips/695httplistener/httplistener.html

https://qiita.com/washikawau/items/bfcd8babcffab30e6d26

https://www.misuzilla.org/Blog/2016/01/31/CreateYourOwnHttpServerUsingCSharp

# Windows 標準の C# コンパイラ

よく知られた話だと思いますが、最近(?)の Windows にはデフォルトで C# コンパイラが入っているようです。

今回使用したものは以下です。

`C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe`

C# には言語のバージョンがありますが、このコンパイラは C# 5 ベースのようですね。

現在（2023 年 3 月）の最新版は C# 11 (2022 年 11 月リリース) なので、C# 5 は結構古いバージョンみたいです。

https://learn.microsoft.com/ja-jp/dotnet/csharp/whats-new/csharp-version-history

上記を見ると C# 5 のリリースは 2012 年 8 月、10 年ちょっと前のようですね。

# 簡易 Web サーバ

```cs:SimpleHttpServer.cs
using System;
using System.IO;
using System.Net;
using System.Text;
using System.Text.RegularExpressions;
using System.Web;

public class SimpleHttpServer
{
    private readonly static string DirSep = Path.DirectorySeparatorChar.ToString();
    private readonly static string ParentMid = DirSep + ".." + DirSep;
    private readonly static string ParentLast = DirSep + "..";

    private static string s_root = "./";
    private static string s_prefix = null;

    public static void Main(string[] args)
    {
        try
        {
            ParseOptions(args);
            string prefixPath = WebUtility.UrlDecode(
                Regex.Replace(s_prefix, "https?://[^/]*", ""));

            using (HttpListener listener = new HttpListener())
            {
                listener.Prefixes.Add(s_prefix);
                listener.Start();
                Console.WriteLine("Listening on " + s_prefix);

                while (true)
                {
                    HttpListenerContext context = listener.GetContext();
                    HttpListenerRequest request = context.Request;

                    using (HttpListenerResponse response = context.Response)
                    {
                        string rawPath = WebUtility.UrlDecode(
                            Regex.Replace(request.RawUrl, "[?;].*$", ""))
                            .Substring(prefixPath.Length-1);

                        if (rawPath == "") {
                            rawPath = "/.";
                        }

                        string path = Regex.Replace(s_root + rawPath, "/+", DirSep);

                        if (path.EndsWith(DirSep) && File.Exists(path + "index.html"))
                        {
                            path += "index.html";
                        }

                        response.ContentLength64 = 0;

                        if (!request.HttpMethod.Equals("GET"))
                        {
                            response.StatusCode = 501; // NotImplemented
                        }
                        else if (path.Contains(ParentMid) || path.EndsWith(ParentLast))
                        {
                            response.StatusCode = 400; // BadRequest
                        }
                        else if (path.EndsWith(DirSep) && Directory.Exists(path))
                        {
                            string indexPage = CreateIndexPage(path, rawPath);
                            byte[] content = Encoding.UTF8.GetBytes(indexPage);
                            response.ContentType = "text/html";
                            response.ContentLength64 = content.Length;
                            response.OutputStream.Write(content, 0, content.Length);
                        }
                        else if (Directory.Exists(path))
                        {
                            response.Headers.Set("Location", request.Url + "/");
                            response.StatusCode = 301; // MovedPermanently
                        }
                        else if (!File.Exists(path))
                        {
                            response.StatusCode = 404; // NotFound
                        }
                        else
                        {
                            try
                            {
                                byte[] content = File.ReadAllBytes(path);
                                response.ContentType = MimeMapping.GetMimeMapping(path);
                                response.ContentLength64 = content.Length;
                                response.OutputStream.Write(content, 0, content.Length);
                            }
                            catch (Exception e)
                            {
                                Console.Error.WriteLine(e);
                                response.StatusCode = 403; // Forbidden
                            }
                        }

                        Console.WriteLine("{0} - - [{1}] \"{2} {3} HTTP/{4}\" {5} {6}",
                            request.RemoteEndPoint.Address,
                            DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss K"),
                            request.HttpMethod,
                            request.RawUrl,
                            request.ProtocolVersion,
                            response.StatusCode,
                            response.ContentLength64);
                    }
                }
            }
        }
        catch (Exception e)
        {
            Console.Error.WriteLine(e);
        }
    }

    private static void ParseOptions(string[] args)
    {
        string port = "8000";
        string host = "+";

        for (int i = 0; i < args.Length; i++)
        {
            if (args[i].Equals("-t"))
            {
                s_prefix = "http://+:80/Temporary_Listen_Addresses/";
            }
            else if (args[i].Equals("-p") && i+1 < args.Length)
            {
                port = args[++i];
            }
            else if (args[i].Equals("-b") && i+1 < args.Length)
            {
                host = args[++i];
            }
            else if (args[i].Equals("-r") && i+1 < args.Length)
            {
                s_root = args[++i];
            }
            else if (args[i].Equals("-P") && i+1 < args.Length)
            {
                s_prefix = args[++i];
            }
            else
            {
                Console.Error.WriteLine(
                    "usage: {0} [-r DIR] [-p PORT] [-b ADDR]\n" +
                    "    or {0} [-r DIR] [-t]\n" +
                    "    or {0} [-r DIR] [-P PREFIX]",
                    AppDomain.CurrentDomain.FriendlyName);
                Environment.Exit(0);
            }
        }

        if (!Directory.Exists(s_root))
        {
            throw new DirectoryNotFoundException(s_root);
        }

        if (s_prefix == null)
        {
            s_prefix = string.Format("http://{0}:{1}/", host, port);
        }
    }

    private static string CreateIndexPage(string path, string urlPath)
    {
        StringBuilder sb = new StringBuilder();
        sb.Append("<html><head><meta charset=\"UTF-8\" /></head>\n");
        sb.AppendFormat("<body><h1>List of {0}</h1><ul>\n",
            WebUtility.HtmlEncode(urlPath));

        if (urlPath != "/")
        {
            sb.Append("<li><a href=\"..\">..</a></li>\n");
        }

        foreach (string file in Directory.GetFileSystemEntries(path))
        {
            string basename = Path.GetFileName(file);
            sb.AppendFormat("<li><a href=\"{0}{2}\">{1}{2}</a></li>\n",
                WebUtility.UrlEncode(basename),
                WebUtility.HtmlEncode(basename),
                Directory.Exists(file) ? "/" : "");
        }

        sb.Append("</ul></body></html>\n");
        return sb.ToString();
    }
}
```

* シングルスレッドで動きます
* `GET` 以外のメソッドには対応していません

コンパイルは、普通にソースファイルを指定するだけです（ `PATH` を通すか、フルパス指定で `csc.exe` を起動してください）。

```
C:\Windows\Microsoft.NET\Framework\v4.0.30319\csc.exe SimpleHttpServer.cs
```

サーバーは、以下の引数をつけて起動します。

* ポート番号を `-p PORT` で指定（未指定時は `8000`）
* IP アドレスを `-b ADDR` で指定（未指定時はどのアドレスでも大丈夫。外からアクセスできないようにするならローカルループバックを指定する）
* ドキュメントルートを `-r DIR` で指定（未指定時はカレントディレクトリ）

```
.\SimpleHttpServer -p 8000 -r .
```

ちなみに、Windows で普通に Web サーバを起動しようとすると管理者権限が必要になるようです。コマンドプロンプトや PowerSehll を管理者権限で起動してから実行するようにしてください。
（あるいは管理者権限で `netsh http add urlacl` コマンドを実行して prefix に権限を追加するか。
試してないけど、

```
netsh http add urlacl url=http://+:8000/ user=Everyone
```

こんな（👆）感じ？）

https://learn.microsoft.com/ja-jp/dotnet/framework/wcf/feature-details/how-to-replace-the-wcf-url-reservation-with-a-restricted-reservation

ただし、管理者権限の不要な URL の prefix が存在するようです。

管理者権限で `netsh http show urlacl` を実行するとその設定が見えるようです。

```
>netsh http show urlacl

URL 予約:
-----------------
（略）

    予約済み URL            : http://+:80/Temporary_Listen_Addresses/
        ユーザー: \Everyone
            リッスン: Yes
            委任: No
            SDDL: D:(A;;GX;;;WD)

（略）
```

Windows のデフォルトでは（管理者が権限を変えていなければ） `http://+:80/Temporary_Listen_Addresses/` で Listen する Web サーバは管理者権限無しでも動作できるようです（ポート番号は `80` 固定、パスも `/Temporary_Listen_Addresses/` 固定）。

このプリフィックスを使いたい場合には、`-t` を指定してください。

```
.\SimpleHttpServer -t
```

SPA 等でこのプリフィックスを使う場合には、上記パスを ( 例えば React なら `package.json` の `"homepage"` とかに）指定してあげないと、うまく動かないかも。注意。

他のプリフィックスを使用したいときには `-P PREFIX` を指定してください。