はじめに
- 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 サーバ
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
を指定してください。