LoginSignup
4
5

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

Last updated at Posted at 2023-03-12

はじめに

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

作ったきっかけ

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

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

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

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

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

【類似の記事】

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 は結構古いバージョンみたいです。

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

簡易 Web サーバ

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

こんな(👆)感じ?)

ただし、管理者権限の不要な 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 を指定してください。

4
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
5