【Deno】標準ライブラリのサーバーにContent-Typeを設定する

毎回ググってる気がするので、忘備録として記事にまとめておきます。
タイトルを正確に言うと「標準ライブラリのサーバーで、Content-Typeを設定したレスポンスを返す」です。

## 標準ライブラリのHTTPサーバー

まず標準ライブラリのHTTPサーバーの書き方をおさらいです

```ts:HelloWorldサーバー
import { serve } from "https://deno.land/std@0.115.1/http/mod.ts";
console.log("http://localhost:8000/");
serve((request: Request) => new Response("Hello World")); // デフォルトはポート8000を使用
```

単純なファイルサーバーとして使うには、次のように書きます。

```ts:ファイルサーバー
import {
  serve,
  Status,
  STATUS_TEXT,
} from "https://deno.land/std@0.115.1/http/mod.ts";

console.log("http://localhost:8000/");
serve(async (request) => {
  try {
    // 読み込むファイルのurlを作成
    const { pathname } = new URL(request.url);
    const url = new URL(`.${pathname}`, import.meta.url);

    // ファイルをfetchしてResponseを返す
    return await fetch(url);
  } catch (error) {
    // NotFoundの時はTypeErrorが発生
    console.log(error);
    const status = Status.NotFound;
    return new Response(`${status} ${STATUS_TEXT.get(status)}`, { status });
  }
});
```

ところで、上記の例ではレスポンスヘッダにContent-Typeが設定されていません。
Content-Typeを付けたい場合は、ファイルの拡張子から自動でContent-Typeを設定する方法があります。

## Content-Typeの設定方法

https://deno.land/std@0.115.1/path/mod.ts と https://deno.land/x/media_types@v2.11.0/mod.ts を使用します。（バージョンは現時点での最新版。適宜アップデートしてください。）

前者は標準ライブラリのpathモジュールです。ファイル名やファイルパスから拡張子を見つけるために使います。

```ts
import { extname } from "https://deno.land/std@0.115.1/path/mod.ts";
console.log(extname("foobar.txt")) //=> ".txt"
console.log(extname(".txt")) //=> ""
```

後者のmedia_typesモジュールは、拡張子からContent-Typeを見つけるために使います。
人気ミドルウェアフレームワークのoak内で維持管理されており、充分安定しています。

```ts
import { contentType } from "https://deno.land/x/media_types@v2.11.0/mod.ts";
console.log(contentType(".txt")) //=> "text/plain; charset=utf-8"
console.log(contentType(".hogehoge")) //=> undefined
console.log(contentType("")) //=> undefined
```

これらのモジュールを使って、ファイルサーバーでファイル名からContent-Typeを自動生成するには、以下のようにします。

```ts:Content-Type付きファイルサーバー
import {
  serve,
  Status,
  STATUS_TEXT,
} from "https://deno.land/std@0.115.1/http/mod.ts";
import { contentType } from "https://deno.land/x/media_types@v2.11.0/mod.ts";
import { extname } from "https://deno.land/std@0.115.1/path/mod.ts";

console.log("http://localhost:8000/");
serve(async (request) => {
  try {
    // 読み込むファイルのurlを作成
    const { pathname } = new URL(request.url);
    const url = new URL(`.${pathname}`, import.meta.url);
    const type = contentType(extname(pathname)) || "text/plain; charset=utf-8";

    // ファイルをfetchしてResponseを返す
    const response = await fetch(url);
    return new Response(response.body, { headers: { "Content-Type": type } });
  } catch (error) {
    // NotFoundの時はTypeErrorが発生
    console.log(error);
    const status = Status.NotFound;
    return new Response(`${status} ${STATUS_TEXT.get(status)}`, { status });
  }
});
```

ここで注意点として、fetchの返り値のResonseオブジェクトに直接Content-Typeを設定することはできません。つまり、以下のコードは実行時エラーになります。

```ts
response.headers.set("Content-Type", type);
```

これは、["fetchのイミュータブルガード"](https://developer.mozilla.org/ja/docs/Web/API/Fetch_API/Basic_concepts#%E3%82%AC%E3%83%BC%E3%83%89)というのがあるらしく、fetchの返り値のResponseオブジェクトは書き換えできない事になっているためです。

書き換えできないため、新しいResponseオブジェクトを作成してContent-Typeを設定する必要があります。

```ts
const response = await fetch(url);
// response.bodyはReadableStream
return new Response(response.body, { headers: { "Content-Type": type } });
```

## おまけ

上に書いたファイルサーバーに

- 末尾スラッシュをindex.htmlにルーティングする機能
- GETリクエスト以外は弾く
- ちゃんとしたエラーハンドリング

を付加したものを置いておきます。

```ts:ちゃんとしたファイルサーバー
import {
  serve,
  Status,
  STATUS_TEXT,
} from "https://deno.land/std@0.115.1/http/mod.ts";
import { contentType } from "https://deno.land/x/media_types@v2.11.0/mod.ts";
import { extname } from "https://deno.land/std@0.115.1/path/mod.ts";

/** 拡張子からcontentTypeを生成する */
function contentTypeFromExt(ext: string): string {
  return contentType(ext) || "text/plain; charset=utf-8";
}
/** ステータスコードからResponseオブジェクトを生成する */
function responseFromStatus(status: Status) {
  return new Response(`${status} ${STATUS_TEXT.get(status)}`, { status });
}

console.log("http://localhost:8000/");

serve(async (request) => {
  try {
    // GETメソッド以外は弾く
    if (request.method !== "GET") {
      return responseFromStatus(Status.MethodNotAllowed);
    }

    // 読み込むファイルのurlを作成
    let { pathname } = new URL(request.url);
    if (pathname.at(-1) === "/") {
      pathname += "index.html";
    }
    const url = new URL(`.${pathname}`, import.meta.url);

    let response;
    try {
      response = await fetch(url);
    } catch {
      // NotFoundの時はTypeErrorが発生
      return responseFromStatus(Status.NotFound);
    }

    // response headerを設定するため、新たにResponseオブジェクトを生成する
    return new Response(response.body, {
      headers: { "Content-Type": contentTypeFromExt(extname(pathname)) },
    });
  } catch (e) {
    console.log(e);
    // エラーが発生した場合は500 Internal Server Error
    return responseFromStatus(Status.InternalServerError);
  }
});
```