LoginSignup
3
2

More than 3 years have passed since last update.

簡易HTTPサーバーでブラウザからファイルの入出力を試す

Last updated at Posted at 2020-07-08

ローカルでスクリプトを動かして処理結果を保存するのと似たような感覚でブラウザからファイルを保存したかったので、Deno でファイルの入出力を仲介するサーバーを作ってみました。

概要

ブラウザで実行してローカルに結果を出力することを想定したスクリプトがあるとします。

  • test.js

ブラウザはスクリプト単体では読み込めないため、読み込むための HTML を用意します。

  • index.html

単にブラウザで動かすだけだとファイルの入出力ができないため、仲介する HTTP サーバーを作ります。

  • server.ts

index.html と test.js は server.ts から提供することもできますし、他のサーバーに置いて server.ts はファイルの受け取りだけをすることもできます。他のサーバーに置く場合、ファイルを受け取るには CORS への対応が必要です。

完成したファイルは以下にあります。

std.http

Deno の標準ライブラリには HTTP サーバーを作るためのコードが用意されています。

ここにある file_server.ts は単独で実行することも、ライブラリとして使用することも可能です。ファイルの最後を見ると、単独で実行されたときだけ main を呼び出すようになっています。

if (import.meta.main) {
  main();
}

※ Python でよく見るやり方です。

Python
if __name__ == "__main__":
    main()

file_server.ts をライブラリとして使用すれば、レスポンスでファイルを返せます。やり方を示すサンプルが用意されています。

import { serve } from "../server.ts";
import { serveFile } from "../file_server.ts";

const server = serve({ port: 8000 });

console.log('Server running...');

for await (const req of server) {
  serveFile(req, './http/testdata/hello.html').then(response => {
    req.respond(response);
  });
}

これをベースに改造します。

await

for の中のコードを簡単にするため await で書き換えます。

  req.respond(await serveFile(req, './http/testdata/hello.html'));

serveFilereq は必要なのか?と思いましたが、コードを見ると、切断時にファイルを閉じていました。

  req.done.then(() => {
    file.close();
  });

もしここで await を使うとレスポンスが返せずに固まってしまうので、使えません。なるほど、.thenawait の使い分けが少し見えました。

server.ts

どんどん書き換えて、GET ではファイルを返して、POST では受け取ったデータをファイルとして保存するようにしました。

server.ts
import { serve } from "https://deno.land/std/http/server.ts";
import { serveFile } from "https://deno.land/std/http/file_server.ts";

const port = 8080;
const server = serve({ port: port });
const serverURL = `http://127.0.0.1:${port}`;
console.log(serverURL);

const te = new TextEncoder();
for await (const req of server) {
    const hostname = (req.conn.remoteAddr as Deno.NetAddr).hostname;
    console.log(hostname, req.method, req.url);
    if (hostname != "127.0.0.1") {
        req.respond({ status: 403 });
        continue;
    }
    let res: any = {};
    switch (req.method) {
        case "GET":
            let url = req.url;
            if (url == "/end") {
                res.body = te.encode("<m>bye!</m>");
                await req.respond(res);
                server.close();
                continue;
            }
            if (url == "/") url = "/index.html";
            try {
                res = await serveFile(req, url.substring(1));
            } catch (e) {
                console.log(e);
                res.status = 404;
            }
            break;
        case "POST":
            let name = req.url.substring(1);
            try {
                Deno.statSync(name);
                console.log(name, req.contentLength, "denied");
                res.status = 403;
                break;
            } catch {}
            try {
                Deno.writeFile(name, await Deno.readAll(req.body));
                console.log(name, req.contentLength, "created");
                res.body = te.encode("<m>created</m>");
            } catch (e) {
                console.log(e);
                console.log(name, req.contentLength, "failed");
                res.status = 403;
            }
            break;
        default:
            res.status = 403;
            break;
    }
    req.respond(res);
}
  • ローカルでの使用が目的で、外部に公開することは想定しません。
  • ローカルホスト以外からの接続は拒否します。
  • //index.html に読み替えます。
  • /end でサーバーを終了します。
  • ファイルの上書きは拒否します。そのため PUT ではなく POST を使用します。
  • /endPOST は XMLHttpRequest からの利用を想定して XML を返します。

test.js

ブラウザで動かすスクリプトです。ファイルを2つ保存します。

test.js
function log(str) {
    result.appendChild(document.createTextNode(str));
    result.appendChild(document.createElement("br"));
}

function send(method, url, body) {
    log(`${method} ${url}`);
    return new Promise((resolve, reject) => {
        let req = new XMLHttpRequest();
        req.open(method, url);
        req.onload = () => {
            log(`response: ${req.status} ${req.response}`);
            resolve();
        };
        if (body) {
            req.send(new Blob([body], { type: "text/plain" }));
        } else {
            req.send();
        }
    });
}

(async () => {
    await send("POST", "1.txt", "hello\n");
    await send("POST", "2.txt", "world\n");
    await send("GET" , "end");
})();

onload でレスポンスを受け取ると send に対する await から抜けます。

index.html

スクリプト単独では動かないため、JavaScript を読み込むための HTML を用意します。div は結果表示用です。

index.html
<!DOCTYPE html>
<html>
    <head>
        <title>File Save Test</title>
    </head>
    <body>
        <div id="result"></div>
        <script src="test.js"></script>
    </body>
</html>

実行結果

server.ts を立ち上げて、ブラウザで http://127.0.0.1:8080 を開くと、test.js が実行されます。test.js から server.ts を終了させます。

$ ls
index.html  server.ts  test.js
$ deno run --allow-net --allow-read --allow-write server.ts
http://127.0.0.1:8080
127.0.0.1 GET /
127.0.0.1 GET /test.js
127.0.0.1 POST /1.txt
1.txt 6 created
127.0.0.1 POST /2.txt
2.txt 6 created
127.0.0.1 GET /end
$ ls
1.txt  2.txt  index.html  server.ts  test.js
ブラウザ
POST 1.txt
response: 200 <m>created</m>
POST 2.txt
response: 200 <m>created</m>
GET end
response: 200 <m>bye!</m>

ファイルができているので、中身を確認します。

$ cat 1.txt
hello
$ cat 2.txt
world

test.js から送った内容が保存されています。

上書きは拒否するように作ったため、再度実行すると次のようになります。

$ deno run --allow-net --allow-read --allow-write server.ts
http://127.0.0.1:8080
127.0.0.1 GET /
127.0.0.1 GET /test.js
127.0.0.1 POST /1.txt
1.txt 6 denied
127.0.0.1 POST /2.txt
2.txt 6 denied
127.0.0.1 GET /end
ブラウザ
POST 1.txt
response: 403
POST 2.txt
response: 403
GET end
response: 200 <m>bye!</m>

CORS

今回は index.html と test.js も自前でホスティングしましたが、これらを別のサイトに置いて結果だけ受け取ることも想定されます。

CORS (Cross-Origin Resource Sharing) によって、デフォルトではサイトをまたいだデータのやり取りは禁止されるためエラーになります。

エラーの例
Access to XMLHttpRequest at 'http://127.0.0.1:8080/end' from origin 'https://foobar' has been blocked
by CORS policy: No 'Access-Control-Allow-Origin' header is present on the requested resource.

このとき、クライアントからは次のようなリクエストが来ます。

origin: https://foo
access-control-request-method: POST
access-control-request-headers: content-type

※ origin はブラウザで開いているサイトです。通信がそのサイトから直接来るわけではなく、そのサイトを見ているブラウザから来ます。

今回のサーバーは、必要なときだけ起動して通信をローカルに限るという前提で、リクエストをそのまま許可します。

server.ts(switchの前に追加)
    res.headers = new Headers();
    const origin = req.headers.get("origin");
    const acrm   = req.headers.get("access-control-request-method");
    const acrh   = req.headers.get("access-control-request-headers");
    if (origin) res.headers.set("Access-Control-Allow-Origin" , origin);
    if (acrm  ) res.headers.set("Access-Control-Allow-Methods", acrm);
    if (acrh  ) res.headers.set("Access-Control-Allow-Headers", acrh);

バイナリファイルの場合、POST する前に OPTIONS で確認が来ます。回答を含んだヘッダを返せば許可したことになります。

server.ts(switchの中に追加)
        case "OPTIONS":
            break;

感想

Deno の前身である Node.js の頃から言われていたことですが、やはりサーバーとクライアント(ブラウザ)を同じ言語(JavaScript)で書けるのは良いですね。

Node.js は非同期が面倒であまり使わなかったのですが、Deno は Promise ベースで async/await が使えるため、コードも読みやすくなっています。

TypeScript がシームレスに使えるのも良いです。今回は TypeScript であることを意識せずに JavaScript と同じような書き方をしましたが、型チェックが働きます。

まだ Deno の情報はあまり多くありませんが、標準ライブラリはそれほど規模が大きくないので、ヒントになりそうなコードを探して読むような進め方をすることになりそうです。

Go との関係

何となく雰囲気が Go に似ていると感じたのですが、実際、標準ライブラリは Go を意識しているようです。

deno_std is a loose port of Go's standard library. When in doubt, simply port Go's source code, documentation, and tests. There are many times when the nature of JavaScript, TypeScript, or Deno itself justifies diverging from Go, but if possible we want to leverage the energy that went into building Go. We generally welcome direct ports of Go's code.

DeepL 翻訳

deno_std は、Go の標準ライブラリの緩やかな移植版です。疑問がある場合は、Goのソースコード、ドキュメント、テストを移植してください。JavaScript、TypeScript、またはDeno自体の性質上、Goからの転用が正当化されることは多々ありますが、可能であれば、Goの構築に費やしたエネルギーを活用したいと考えています。私たちは一般的に、Go のコードを直接移植することを歓迎します。

関連記事

今回の記事で作ったサーバーを利用する例です。

ブラウザで結果を表示するコードは以下の記事に由来します。

サーバーの書き方はプル型パーサーに似ていると思いました。

参考

hostname の取得で req.conn.remoteAddr as Deno.NetAddr とキャストしている部分は以下の issue を参考にしました。

今回の記事とよく似たコードが出てきて興味深い記事です。

今回の記事とは別の Servest というライブラリを使用して、凝ったサーバーを作る記事です。

POSTPUT の違いについて参考にしました。

XMLHttpRequest の使い方について参考にしました。

CORS について参考にしました。

3
2
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
3
2