Node.js

ある学生が尋ねた。「昔のプログラマーは、単純な機械とプログラミング言語を使わずに、美しいプログラムを作っていました。なぜ私たちは複雑な機械やプログラミング言語を使うのですか?」フー・ツーは答えた。「昔の建築家は、棒と粘土だけを使って、美しい小屋を作ったのだ。」

マスター・ユアン・マ、『プログラミングの書』
Illustration showing a telephone pole with a tangle of wires going in all directions

これまで、私たちは JavaScript 言語を単一の環境、つまりブラウザで使用してきました。この章と次の章では、ブラウザ以外で JavaScript のスキルを応用できるプログラムである Node.js について簡単に紹介します。これを使用すると、小さなコマンドラインツールから、動的な Web サイトを動かす HTTP サーバーまで、あらゆるものを構築できます。

これらの章は、Node.js が使用する主な概念を教え、Node.js で役立つプログラムを作成するのに十分な情報を提供することを目的としています。プラットフォームの完全な、あるいは徹底的な解説を目指すものではありません。

前の章のコードは、生の JavaScript であるか、ブラウザ向けに書かれているため、これらのページで直接実行できましたが、この章のコードサンプルは Node 向けに書かれており、ブラウザでは実行できないことがよくあります。

この章を読んでコードを実行したい場合は、Node.js バージョン 18 以上をインストールする必要があります。インストールするには、https://node.dokyumento.jp にアクセスし、お使いのオペレーティングシステムの手順に従ってインストールしてください。Node.js の詳細なドキュメントもそこで見つけることができます。

背景

ネットワークを介して通信するシステムを構築する場合、入出力、つまりネットワークとハードドライブへのデータの読み取りと書き込みをどのように管理するかは、システムがユーザーまたはネットワークリクエストにどれだけ迅速に応答するかに大きな違いをもたらす可能性があります。

このようなプログラムでは、非同期プログラミングが役立つことがよくあります。これにより、プログラムは複雑なスレッド管理や同期を行わずに、複数のデバイスから同時にデータを送受信できます。

Node は、非同期プログラミングを簡単かつ便利にすることを目的に最初に考案されました。JavaScript は Node のようなシステムに適しています。JavaScript は、入出力を実行するための組み込みの方法を持たない数少ないプログラミング言語の 1 つです。したがって、JavaScript は、2 つの一貫性のないインターフェイスで終わることなく、ネットワークおよびファイルシステムプログラミングに対する Node のかなり型破りなアプローチに適合させることができました。2009 年に Node が設計されていたとき、人々はすでにブラウザでコールバックベースのプログラミングを行っていたため、言語を取り巻くコミュニティは非同期プログラミングスタイルに慣れていました。

node コマンド

Node.js がシステムにインストールされている場合、JavaScript ファイルを実行するために使用される node というプログラムが提供されます。次のコードを含むファイル hello.js があるとします。

let message = "Hello world";
console.log(message);

コマンドラインから次のように node を実行して、プログラムを実行できます。

$ node hello.js
Hello world

Node の console.log メソッドは、ブラウザで行うことと似たことを行います。テキストを出力します。ただし、Node では、テキストはブラウザの JavaScript コンソールではなく、プロセスの標準出力ストリームに送られます。コマンドラインから node を実行する場合、ターミナルにログに記録された値が表示されます。

ファイルを指定せずに node を実行すると、JavaScript コードを入力してすぐに結果を確認できるプロンプトが表示されます。

$ node
> 1 + 1
2
> [-1, -2, -3].map(Math.abs)
[1, 2, 3]
> process.exit(0)
$

console バインディングと同様に、process バインディングは Node でグローバルに使用できます。これは、現在のプログラムを検査および操作するためのさまざまな方法を提供します。exit メソッドはプロセスを終了し、終了ステータスコードを渡すことができます。これにより、node を起動したプログラム(この場合はコマンドラインシェル)に、プログラムが正常に完了したか(コードゼロ)、エラーが発生したか(他のコード)を通知します。

スクリプトに渡されたコマンドライン引数を見つけるには、文字列の配列である process.argv を読み取ることができます。これには node コマンドの名前とスクリプト名も含まれているため、実際の引数はインデックス 2 から始まることに注意してください。showargv.js にステートメント console.log(process.argv) が含まれている場合、次のように実行できます。

$ node showargv.js one --and two
["node", "/tmp/showargv.js", "one", "--and", "two"]

ArrayMathJSON など、標準の JavaScript グローバルバインディングはすべて、Node の環境にも存在します。documentprompt などのブラウザ関連の機能はありません。

モジュール

consoleprocess など、前述のバインディング以外に、Node はいくつかの追加のバインディングをグローバルスコープに配置します。組み込みの機能にアクセスする場合は、モジュールシステムに要求する必要があります。

Node は、第 10 章で見た require 関数に基づく CommonJS モジュールシステムの使用を開始しました。.js ファイルをロードすると、デフォルトでこのシステムが使用されます。

ただし、今日では、Node はより最新の ES モジュールシステムもサポートしています。スクリプトのファイル名が .mjs で終わる場合、それはそのようなモジュールであるとみなされ、その中で import および export を使用できます(ただし、require は使用できません)。この章では ES モジュールを使用します。

モジュールをインポートする場合、require でも import でも、Node は指定された文字列をロードできる実際のファイルに解決する必要があります。/./、または ../ で始まる名前は、現在のモジュールのパスを基準としたファイルとして解決されます。ここで、. は現在のディレクトリ、../ は 1 つ上のディレクトリ、/ はファイルシステムのルートを表します。ファイル /tmp/robot/robot.mjs から "./graph.mjs" を要求すると、Node はファイル /tmp/robot/graph.mjs をロードしようとします。

相対パスまたは絶対パスのように見えない文字列がインポートされると、組み込みモジュールまたは node_modules ディレクトリにインストールされたモジュールのいずれかを参照しているとみなされます。たとえば、"node:fs" からインポートすると、Node の組み込みファイルシステムモジュールが得られます。"robot" をインポートすると、node_modules/robot/ にあるライブラリをロードしようとする可能性があります。このようなライブラリは、後で説明する NPM を使用してインストールするのが一般的です。

2 つのファイルで構成される小さなプロジェクトをセットアップしましょう。最初のファイルは main.mjs という名前で、文字列を反転するためにコマンドラインから呼び出すことができるスクリプトを定義します。

import {reverse} from "./reverse.mjs";

// Index 2 holds the first actual command line argument
let argument = process.argv[2];

console.log(reverse(argument));

ファイル reverse.mjs は、このコマンドラインツールと、文字列反転関数への直接アクセスを必要とする他のスクリプトの両方で使用できる、文字列を反転するためのライブラリを定義します。

export function reverse(string) {
  return Array.from(string).reverse().join("");
}

export は、バインディングがモジュールのインターフェイスの一部であることを宣言するために使用されることを思い出してください。これにより、main.mjs は関数をインポートして使用できます。

これで、次のようにツールを呼び出すことができます。

$ node main.mjs JavaScript
tpircSavaJ

NPM を使用したインストール

第 10 章で紹介した NPM は、JavaScript モジュールのオンラインリポジトリであり、その多くは Node 向けに特別に書かれています。コンピューターに Node をインストールすると、このリポジトリと対話するために使用できる npm コマンドも取得できます。

NPM の主な用途は、パッケージのダウンロードです。第 10 章ini パッケージについて説明しました。NPM を使用して、そのパッケージを取得し、コンピューターにインストールできます。

$ npm install ini
added 1 package in 723ms

$ node
> const {parse} = require("ini");
> parse("x = 1\ny = 2");
{ x: '1', y: '2' }

npm install を実行すると、NPM は node_modules というディレクトリを作成します。そのディレクトリの中には、ライブラリを含む ini ディレクトリがあります。それを開いてコードを見ることができます。"ini" をインポートすると、このライブラリがロードされ、その parse プロパティを呼び出して構成ファイルを解析できます。

デフォルトでは、NPM はパッケージを一元的な場所ではなく、現在のディレクトリにインストールします。他のパッケージマネージャーに慣れている場合は、これは異常に見えるかもしれませんが、利点があります。各アプリケーションはインストールするパッケージを完全に制御でき、アプリケーションを削除するときにバージョンの管理やクリーンアップが簡単になります。

パッケージファイル

いくつかのパッケージをインストールするために npm install を実行すると、node_modules ディレクトリだけでなく、現在のディレクトリに package.json というファイルも見つかります。各プロジェクトにこのようなファイルを用意することをお勧めします。手動で作成するか、npm init を実行できます。このファイルには、名前やバージョンなど、プロジェクトに関する情報が含まれており、依存関係が一覧表示されます。

第 7 章のロボットシミュレーションを、第 10 章の演習でモジュール化したものは、次のような package.json ファイルを持つ可能性があります。

{
  "author": "Marijn Haverbeke",
  "name": "eloquent-javascript-robot",
  "description": "Simulation of a package-delivery robot",
  "version": "1.0.0",
  "main": "run.mjs",
  "dependencies": {
    "dijkstrajs": "^1.0.1",
    "random-item": "^1.0.0"
  },
  "license": "ISC"
}

npm install をパッケージ名を指定せずに実行すると、NPM は package.json にリストされている依存関係をインストールします。依存関係としてまだリストされていない特定のパッケージをインストールすると、NPM はそれを package.json に追加します。

バージョン

package.json ファイルには、プログラム自体のバージョンと、その依存関係のバージョンがリストされています。バージョンは、パッケージが個別に進化するという事実に対処するための方法であり、ある時点でのパッケージで動作するように書かれたコードは、後で変更されたバージョンのパッケージでは動作しない可能性があります。

NPM は、そのパッケージがセマンティックバージョニングと呼ばれるスキーマに従うことを要求します。これは、どのバージョンが互換性があるか(古いインターフェースを壊さないか)に関する情報をバージョン番号にエンコードしたものです。セマンティックバージョンは、2.3.0 のように、ピリオドで区切られた 3 つの数字で構成されます。新しい機能が追加されるたびに、真ん中の数字をインクリメントする必要があります。互換性が壊れ、パッケージを使用する既存のコードが新しいバージョンでは動作しない可能性がある場合は、最初の数字をインクリメントする必要があります。

package.json の依存関係のバージョン番号の前にあるキャレット文字 (^) は、指定された番号と互換性のある任意のバージョンをインストールできることを示します。たとえば、"^2.3.0" は、2.3.0 以上で 3.0.0 未満の任意のバージョンが許可されることを意味します。

npm コマンドは、新しいパッケージまたはパッケージの新しいバージョンを公開するためにも使用されます。package.json ファイルがあるディレクトリで npm publish を実行すると、JSON ファイルにリストされている名前とバージョンのパッケージがレジストリに公開されます。誰でも NPM にパッケージを公開できます。ただし、まだ使用されていないパッケージ名でのみです。ランダムな人が既存のパッケージを更新できると、良くないためです。

この本では、NPM の使用法の詳細については深く掘り下げません。詳細なドキュメントとパッケージを検索する方法については、https://npmjs.com を参照してください。

ファイルシステムモジュール

Node で最も一般的に使用される組み込みモジュールの 1 つは node:fs モジュールで、これはファイルシステムを表します。ファイルとディレクトリを操作するための関数をエクスポートします。

たとえば、readFile という関数はファイルを読み取り、ファイルのコンテンツを使用してコールバックを呼び出します。

import {readFile} from "node:fs";
readFile("file.txt", "utf8", (error, text) => {
  if (error) throw error;
  console.log("The file contains:", text);
});

readFile の 2 番目の引数は、ファイルを文字列にデコードするために使用される文字エンコーディングを示します。テキストをバイナリデータにエンコードする方法はいくつかありますが、ほとんどの最新システムでは UTF-8 が使用されています。別のエンコーディングが使用されていると思われる理由がない限り、テキストファイルを読み取る場合は "utf8" を渡します。エンコーディングを渡さない場合、Node はバイナリデータに関心があるとみなし、文字列の代わりに Buffer オブジェクトを返します。これは、ファイル内のバイト(8 ビットのデータチャンク)を表す数値を含む配列のようなオブジェクトです。

import {readFile} from "node:fs";
readFile("file.txt", (error, buffer) => {
  if (error) throw error;
  console.log("The file contained", buffer.length, "bytes.",
              "The first byte is:", buffer[0]);
});

同様の関数である writeFile は、ファイルをディスクに書き込むために使用されます。

import {writeFile} from "node:fs";
writeFile("graffiti.txt", "Node was here", err => {
  if (err) console.log(`Failed to write file: ${err}`);
  else console.log("File written.");
});

ここではエンコーディングを指定する必要はありませんでした。writeFile は、書き込む文字列が Buffer オブジェクトではなく文字列である場合、デフォルトの文字エンコーディングである UTF-8 を使用してテキストとして書き出すと想定します。

node:fs モジュールには、他にも多くの便利な関数が含まれています。readdir はディレクトリ内のファイルを文字列の配列として提供し、stat はファイルに関する情報を取得し、rename はファイルの名前を変更し、unlink はファイルを削除するなどです。詳細については、https://node.dokyumento.jp のドキュメントを参照してください。

これらのほとんどは、最後のパラメータとしてコールバック関数を受け取り、エラー(最初の引数)または成功した結果(2番目の引数)のいずれかでコールします。 第11章で見たように、このプログラミングスタイルには欠点があります。最大の欠点は、エラー処理が冗長でエラーが発生しやすいことです。

node:fs/promises モジュールは、古い node:fs モジュールと同じ関数のほとんどをエクスポートしますが、コールバック関数の代わりに Promise を使用します。

import {readFile} from "node:fs/promises";
readFile("file.txt", "utf8")
  .then(text => console.log("The file contains:", text));

非同期処理が必要ない場合や、邪魔になるだけの場合があります。node:fs の多くの関数には、末尾に Sync が追加された同じ名前の同期バージョンもあります。たとえば、readFile の同期バージョンは readFileSync と呼ばれます。

import {readFileSync} from "node:fs";
console.log("The file contains:",
            readFileSync("file.txt", "utf8"));

このような同期処理が実行されている間、プログラムは完全に停止することに注意してください。ユーザーやネットワーク上の他のマシンに応答する必要がある場合、同期アクションでスタックすると、迷惑な遅延が発生する可能性があります。

HTTP モジュール

もう 1 つの中央モジュールは node:http と呼ばれます。HTTP サーバーを実行するための機能を提供します。

これは HTTP サーバーを起動するために必要なすべてです

import {createServer} from "node:http";
let server = createServer((request, response) => {
  response.writeHead(200, {"Content-Type": "text/html"});
  response.write(`
    <h1>Hello!</h1>
    <p>You asked for <code>${request.url}</code></p>`);
  response.end();
});
server.listen(8000);
console.log("Listening! (port 8000)");

このスクリプトを自分のマシンで実行する場合は、Web ブラウザで https://127.0.0.1:8000/hello を指定してサーバーにリクエストを送信できます。小さな HTML ページで応答します。

createServer の引数として渡された関数は、クライアントがサーバーに接続するたびに呼び出されます。requestresponse のバインディングは、送受信されるデータを表すオブジェクトです。最初のオブジェクトには、リクエストがどの URL に行われたかを示す url プロパティなど、リクエストに関する情報が含まれています。

ブラウザでそのページを開くと、自分のコンピュータにリクエストが送信されます。これにより、サーバー関数が実行され、応答が返され、ブラウザで確認できます。

クライアントに何かを送信するには、response オブジェクトでメソッドを呼び出します。最初の writeHead は、応答ヘッダーを書き出します(第18章を参照)。ステータスコード(この場合は「OK」の 200)と、ヘッダー値を含むオブジェクトを指定します。この例では、Content-Type ヘッダーを設定して、HTML ドキュメントを返送することをクライアントに通知します。

次に、実際のレスポンスボディ(ドキュメント自体)が response.write で送信されます。たとえば、データが利用可能になるとクライアントにストリーミングする場合など、レスポンスを少しずつ送信する場合は、このメソッドを複数回呼び出すことができます。最後に、response.end はレスポンスの終了を示します。

server.listen の呼び出しにより、サーバーはポート 8000 で接続を待機し始めます。そのため、デフォルトのポート 80 を使用する localhost ではなく、このサーバーと通信するには localhost:8000 に接続する必要があります。

このスクリプトを実行すると、プロセスはそこで待機します。スクリプトがイベント(この場合はネットワーク接続)をリッスンしている場合、node はスクリプトの最後に到達しても自動的に終了しません。終了するには、ctrl-C を押します。

実際の Web サーバーは通常、例にあるものよりも多くのことを行います。クライアントが実行しようとしているアクションを確認するためにリクエストのメソッド(method プロパティ)を確認し、このアクションがどのリソースで実行されているかを確認するためにリクエストの URL を確認します。より高度なサーバーについては、この章の後半のファイルサーバーで説明します。

node:http モジュールは、HTTP リクエストを行うために使用できる request 関数も提供します。ただし、第18章で説明した fetch よりも使い勝手がはるかに悪いです。幸いなことに、fetch は Node でもグローバルバインディングとして利用できます。ネットワーク経由でデータが到着したときにレスポンスドキュメントを少しずつ処理するなど、非常に具体的なことをしたい場合を除き、fetch を使用することをお勧めします。

ストリーム

HTTP サーバーが書き込むことができるレスポンスオブジェクトは、Node で広く使用されている概念である書き込み可能ストリームオブジェクトの例です。このようなオブジェクトには、ストリームに何かを書き込むために文字列または Buffer オブジェクトを渡すことができる write メソッドがあります。それらの end メソッドはストリームを閉じ、オプションで閉じる前にストリームに書き込む値を指定できます。これらのメソッドの両方に、追加の引数としてコールバックを指定することもでき、書き込みまたは閉じが完了するとコールバックが呼び出されます。

node:fs モジュールの createWriteStream 関数を使用して、ファイルを示す書き込み可能ストリームを作成できます。その後、writeFile のように一括ではなく、結果のオブジェクトで write メソッドを使用して、ファイルを一度に 1 つずつ書き込むことができます。

読み取り可能ストリームはもう少し複雑です。HTTP サーバーのコールバックへの request 引数は、読み取り可能ストリームです。ストリームからの読み取りは、メソッドではなくイベントハンドラーを使用して行われます。

Nodeでイベントを発行するオブジェクトには、ブラウザのaddEventListenerメソッドに似たonというメソッドがあります。これにイベント名と関数を渡すと、指定されたイベントが発生するたびにその関数が呼び出されるように登録します。

Readableストリームには、"data"イベントと"end"イベントがあります。最初のイベントはデータが入ってくるたびに発生し、2番目のイベントはストリームが終了したときに呼び出されます。このモデルは、ドキュメント全体がまだ利用可能でない場合でも、すぐに処理できるストリーミングデータに最も適しています。ファイルは、node:fscreateReadStream関数を使用してreadableストリームとして読み込むことができます。

このコードは、リクエストボディを読み取り、すべて大文字のテキストとしてクライアントにストリーミングで返すサーバーを作成します。

import {createServer} from "node:http";
createServer((request, response) => {
  response.writeHead(200, {"Content-Type": "text/plain"});
  request.on("data", chunk =>
    response.write(chunk.toString().toUpperCase()));
  request.on("end", () => response.end());
}).listen(8000);

データハンドラーに渡されるchunkの値はバイナリのBufferになります。これをUTF-8エンコードされた文字としてデコードすることで、toStringメソッドを使って文字列に変換できます。

次のコードを大文字化サーバーがアクティブな状態で実行すると、そのサーバーにリクエストを送信し、受け取ったレスポンスを出力します。

fetch("https://127.0.0.1:8000/", {
  method: "POST",
  body: "Hello server"
}).then(resp => resp.text()).then(console.log);
// → HELLO SERVER

ファイルサーバー

HTTPサーバーとファイルシステムの操作に関する新しい知識を組み合わせて、両者の橋渡しとなるもの、つまりファイルシステムへのリモートアクセスを可能にするHTTPサーバーを作成しましょう。このようなサーバーにはあらゆる用途があります。Webアプリケーションがデータを保存および共有できるようにしたり、グループで多数のファイルへの共有アクセスを許可したりできます。

ファイルをHTTPリソースとして扱う場合、HTTPメソッドのGETPUT、およびDELETEを使用して、それぞれファイルの読み取り、書き込み、削除を行うことができます。リクエストのパスは、リクエストが参照するファイルのパスとして解釈します。

ファイルシステム全体を共有したくないため、これらのパスはサーバーの作業ディレクトリ(サーバーが起動されたディレクトリ)から開始すると解釈します。/tmp/public/(またはWindowsの場合はC:\tmp\public\)からサーバーを実行した場合、/file.txtのリクエストは/tmp/public/file.txt(またはC:\tmp\public\file.txt)を参照する必要があります。

プログラムを段階的に構築し、さまざまなHTTPメソッドを処理する関数を保存するためにmethodsというオブジェクトを使用します。メソッドハンドラーは、リクエストオブジェクトを引数として受け取り、レスポンスを記述するオブジェクトに解決されるPromiseを返すasync関数です。

import {createServer} from "node:http";

const methods = Object.create(null);

createServer((request, response) => {
  let handler = methods[request.method] || notAllowed;
  handler(request).catch(error => {
    if (error.status != null) return error;
    return {body: String(error), status: 500};
  }).then(({body, status = 200, type = "text/plain"}) => {
    response.writeHead(status, {"Content-Type": type});
    if (body?.pipe) body.pipe(response);
    else response.end(body);
  });
}).listen(8000);

async function notAllowed(request) {
  return {
    status: 405,
    body: `Method ${request.method} not allowed.`
  };
}

これは、405エラー応答のみを返すサーバーを起動します。これは、サーバーが指定されたメソッドの処理を拒否することを示すために使用されるコードです。

リクエストハンドラーのPromiseが拒否された場合、catch呼び出しは、エラーをレスポンスオブジェクトに変換します(まだオブジェクトでない場合)。これにより、サーバーはリクエストの処理に失敗したことをクライアントに通知するためにエラーレスポンスを返信できます。

レスポンス記述のstatusフィールドは省略できます。省略した場合、デフォルトは200(OK)になります。typeプロパティのコンテンツタイプも省略でき、省略した場合はレスポンスがプレーンテキストであると見なされます。

bodyの値がreadableストリームの場合、readableストリームからwritableストリームにすべてのコンテンツを転送するために使用できるpipeメソッドがあります。そうでない場合は、null(ボディなし)、文字列、またはバッファーのいずれかであると見なされ、レスポンスのendメソッドに直接渡されます。

どのファイルパスがリクエストURLに対応するかを判断するために、urlPath関数は組み込みのURLクラス(ブラウザーにも存在する)を使用してURLを解析します。このコンストラクターは、request.urlから取得するスラッシュで始まる部分だけでなく、完全なURLを想定しているため、ダミーのドメイン名を指定して埋めます。そのパス名("/file.txt"のようなもの)を抽出し、それをデコードして%20形式のエスケープコードを取り除き、プログラムの作業ディレクトリを基準に解決します。

import {resolve, sep} from "node:path";

const baseDirectory = process.cwd();

function urlPath(url) {
  let {pathname} = new URL(url, "http://d");
  let path = resolve(decodeURIComponent(pathname).slice(1));
  if (path != baseDirectory &&
      !path.startsWith(baseDirectory + sep)) {
    throw {status: 403, body: "Forbidden"};
  }
  return path;
}

ネットワークリクエストを受け入れるようにプログラムを設定したらすぐに、セキュリティについて心配し始める必要があります。この場合、注意しないと、ファイルシステム全体を誤ってネットワークに公開してしまう可能性があります。

ファイルパスはNodeの文字列です。このような文字列を実際のファイルにマッピングするには、かなりの量の解釈が行われます。たとえば、パスには親ディレクトリを参照するための../を含めることができます。明らかな問題の1つは、/../secret_fileのようなパスのリクエストです。

このような問題を回避するために、urlPathnode:pathモジュールのresolve関数を使用し、相対パスを解決します。次に、結果が作業ディレクトリよりも下にあることを確認します。process.cwd関数(cwdcurrent working directoryの略)を使用して、この作業ディレクトリを見つけることができます。node:pathパッケージのsepバインディングは、システムのパス区切り文字です。Windowsではバックスラッシュ、他のほとんどのシステムではフォワードスラッシュです。パスがベースディレクトリで始まらない場合、関数はリソースへのアクセスが禁止されていることを示すHTTPステータスコードを使用して、エラーレスポンスオブジェクトをスローします。

ディレクトリを読み取るときにファイルのリストを返し、通常のファイルを読み取るときにファイルの内容を返すようにGETメソッドを設定します。

難しい問題の1つは、ファイルの内容を返すときに設定する必要があるContent-Typeヘッダーの種類です。これらのファイルは何でもあり得るため、サーバーはすべてのファイルに同じコンテンツタイプを返すことはできません。ここでもNPMが役立ちます。mime-typesパッケージ(text/plainのようなコンテンツタイプインジケーターはMIMEタイプとも呼ばれます)は、多数のファイル拡張子の正しいタイプを知っています。

次のnpmコマンドは、サーバーのスクリプトがあるディレクトリで、特定のバージョンのmimeをインストールします。

$ npm install mime-types@2.1.0

リクエストされたファイルが存在しない場合、返す正しいHTTPステータスコードは404です。ファイルの存在と、それがディレクトリかどうかを調べるために、ファイルに関する情報を調べるstat関数を使用します。

import {createReadStream} from "node:fs";
import {stat, readdir} from "node:fs/promises";
import {lookup} from "mime-types";

methods.GET = async function(request) {
  let path = urlPath(request.url);
  let stats;
  try {
    stats = await stat(path);
  } catch (error) {
    if (error.code != "ENOENT") throw error;
    else return {status: 404, body: "File not found"};
  }
  if (stats.isDirectory()) {
    return {body: (await readdir(path)).join("\n")};
  } else {
    return {body: createReadStream(path),
            type: lookup(path)};
  }
};

ディスクにアクセスする必要があり、時間がかかる可能性があるため、statは非同期です。コールバックスタイルではなくPromiseを使用しているため、node:fsから直接ではなく、node:fs/promisesからインポートする必要があります。

ファイルが存在しない場合、statcodeプロパティが"ENOENT"のエラーオブジェクトをスローします。これらのややあいまいな、Unixにインスパイアされたコードは、Nodeでエラータイプを認識する方法です。

statによって返されるstatsオブジェクトは、ファイルサイズ(sizeプロパティ)や変更日(mtimeプロパティ)など、ファイルに関するさまざまな情報を示します。ここでは、ディレクトリであるか通常のファイルであるかという点に関心があり、これはisDirectoryメソッドによって示されます。

readdirを使用してディレクトリ内のファイルの配列を読み取り、クライアントに返します。通常のファイルの場合、createReadStreamでreadableストリームを作成し、mimeパッケージがファイル名に対して提供するコンテンツタイプとともに、それをボディとして返します。

DELETEリクエストを処理するコードは少し簡単です。

import {rmdir, unlink} from "node:fs/promises";

methods.DELETE = async function(request) {
  let path = urlPath(request.url);
  let stats;
  try {
    stats = await stat(path);
  } catch (error) {
    if (error.code != "ENOENT") throw error;
    else return {status: 204};
  }
  if (stats.isDirectory()) await rmdir(path);
  else await unlink(path);
  return {status: 204};
};

HTTPレスポンスにデータが含まれていない場合は、ステータスコード204(「コンテンツなし」)を使用してこれを示すことができます。削除への応答は、操作が成功したかどうか以外に情報を送信する必要がないため、ここで返すのは賢明です。

存在しないファイルを削除しようとすると、エラーではなく成功ステータスコードが返されるのはなぜだろうと思われるかもしれません。削除するファイルがない場合、リクエストの目的はすでに達成されていると言えます。HTTP標準では、リクエストをべき等にすることを推奨しています。つまり、同じリクエストを複数回行うと、1回行った場合と同じ結果が得られます。ある意味で、すでに存在しないものを削除しようとすると、作成しようとしていた効果が達成されています。つまり、それはもはや存在しません。

これはPUTリクエストのハンドラーです。

import {createWriteStream} from "node:fs";

function pipeStream(from, to) {
  return new Promise((resolve, reject) => {
    from.on("error", reject);
    to.on("error", reject);
    to.on("finish", resolve);
    from.pipe(to);
  });
}

methods.PUT = async function(request) {
  let path = urlPath(request.url);
  await pipeStream(request, createWriteStream(path));
  return {status: 204};
};

今回はファイルの存在を確認する必要はありません。存在する場合は、上書きするだけです。ここでも、pipeを使って、読み取り可能なストリームから書き込み可能なストリームへ、この場合はリクエストからファイルへデータを移動します。しかし、pipeはPromiseを返すように書かれていないため、pipeの呼び出しの結果をラップするPromiseであるpipeStreamというラッパーを書く必要があります。

ファイルのオープン時に問題が発生した場合、createWriteStreamは依然としてストリームを返しますが、そのストリームは"error"イベントを発火します。リクエストからのストリームも、例えばネットワークがダウンした場合など、失敗する可能性があります。そのため、両方のストリームの"error"イベントをPromiseをrejectするように接続します。pipeが完了すると、出力ストリームを閉じ、それによって"finish"イベントが発火します。それが、Promiseを正常に解決できる時点です(何も返さない)。

サーバーの完全なスクリプトはhttps://eloquentjavascript.dokyumento.jp/code/file_server.mjsで入手できます。それをダウンロードし、依存関係をインストールした後、Nodeで実行して独自のファイルサーバーを起動できます。そしてもちろん、この章の演習を解決したり、実験したりするために、それを修正および拡張できます。

Unix系システム(macOSやLinuxなど)で広く利用可能なコマンドラインツールcurlは、HTTPリクエストを行うために使用できます。次のセッションでは、サーバーを簡単にテストします。-Xオプションはリクエストのメソッドを設定するために使用され、-dはリクエストボディを含めるために使用されます。

$ curl https://127.0.0.1:8000/file.txt
File not found
$ curl -X PUT -d CONTENT https://127.0.0.1:8000/file.txt
$ curl https://127.0.0.1:8000/file.txt
CONTENT
$ curl -X DELETE https://127.0.0.1:8000/file.txt
$ curl https://127.0.0.1:8000/file.txt
File not found

file.txtへの最初の要求は、ファイルがまだ存在しないため失敗します。PUTリクエストでファイルが作成され、ご覧のとおり、次のリクエストは正常に取得されます。DELETEリクエストで削除した後、ファイルは再び失われます。

まとめ

Nodeは、ブラウザ以外のコンテキストでJavaScriptを実行できる、優れた小さなシステムです。もともとはネットワーク内のノードの役割を果たすネットワークタスクのために設計されましたが、あらゆる種類のスクリプトタスクに適しています。JavaScriptを書くのが好きな場合は、Nodeでタスクを自動化するのがうまくいくでしょう。

NPMは、考えられるすべてのもの(そしておそらく決して考えないであろうもの)のパッケージを提供し、npmプログラムでそれらのパッケージを取得してインストールできます。Nodeには、ファイルシステムを操作するためのnode:fsモジュールや、HTTPサーバーを実行するためのnode:httpモジュールなど、多くの組み込みモジュールが付属しています。

Nodeでのすべての入出力は、readFileSyncなどの関数の同期バリアントを明示的に使用しない限り、非同期で行われます。Nodeは当初、非同期機能にコールバックを使用していましたが、node:fs/promisesパッケージはファイルシステムへのPromiseベースのインターフェースを提供します。

演習

検索ツール

Unixシステムには、正規表現でファイルをすばやく検索するために使用できるgrepというコマンドラインツールがあります。

コマンドラインから実行でき、grepのような動作をするNodeスクリプトを作成します。最初のコマンドライン引数を正規表現として扱い、それ以降の引数を検索するファイルとして扱います。コンテンツが正規表現に一致するファイルの名前を出力します。

それが機能したら、引数の1つがディレクトリの場合、そのディレクトリとそのサブディレクトリ内のすべてのファイルを検索するように拡張します。

必要に応じて、非同期または同期のファイルシステム関数を使用します。複数の非同期アクションを同時にリクエストするように設定すると、少しスピードアップするかもしれませんが、ほとんどのファイルシステムは一度に1つのことしか読み取ることができないため、大きな違いはありません。

ヒントを表示...

最初のコマンドライン引数である正規表現は、process.argv[2]にあります。入力ファイルはその後ろに続きます。文字列から正規表現オブジェクトに変換するには、RegExpコンストラクタを使用できます。

readFileSyncを使用して同期的にこれを行う方が簡単ですが、node:fs/promisesを使用してPromiseを返す関数を取得し、async関数を作成すると、コードは似たようなものになります。

何かがディレクトリであるかどうかを判断するには、再びstat(またはstatSync)とstatsオブジェクトのisDirectoryメソッドを使用できます。

ディレクトリの探索は分岐プロセスです。再帰関数を使用するか、探索する必要のあるファイル(作業)の配列を保持することで実行できます。ディレクトリ内のファイルを見つけるには、readdirまたはreaddirSyncを呼び出すことができます。奇妙な大文字小文字の区別に注意してください。Nodeのファイルシステム関数の命名は、すべて小文字であるreaddirなどの標準Unix関数に緩やかに基づいていますが、その後、大文字でSyncを追加します。

readdirで読み取ったファイル名から完全なパス名に変換するには、ディレクトリの名前と組み合わせて、node:pathsepをそれらの間に入れるか、同じパッケージのjoin関数を使用する必要があります。

ディレクトリの作成

ファイルサーバーのDELETEメソッドは(rmdirを使用して)ディレクトリを削除できますが、サーバーは現在ディレクトリを作成する方法を提供していません。

node:fsモジュールからmkdirを呼び出すことでディレクトリを作成するMKCOLメソッド(「コレクションの作成」)のサポートを追加します。MKCOLは広く使用されているHTTPメソッドではありませんが、ドキュメントを作成するのに適したHTTPの上に一連の規則を指定するWebDAV標準で、同じ目的で存在します。

ヒントを表示...

DELETEメソッドを実装する関数をMKCOLメソッドの青写真として使用できます。ファイルが見つからない場合は、mkdirでディレクトリを作成しようとします。そのパスにディレクトリが存在する場合は、ディレクトリ作成リクエストが冪等になるように204レスポンスを返すことができます。ディレクトリではないファイルがここに存在する場合は、エラーコードを返します。コード400(「不正なリクエスト」)が適切です。

Web上の公共スペース

ファイルサーバーはあらゆる種類のファイルを提供し、適切なContent-Typeヘッダーも含まれているため、Webサイトを提供するのに使用できます。このサーバーは誰でもファイルを削除および置き換えることができるため、これは興味深い種類のWebサイトになります。適切なHTTPリクエストを行うのに時間をかけた人なら誰でも修正、改善、改ざんできるWebサイトです。

簡単なJavaScriptファイルを含む基本的なHTMLページを作成します。ファイルサーバーで提供されるディレクトリにファイルを配置し、ブラウザで開きます。

次に、高度な演習として、または週末のプロジェクトとして、この本から得たすべての知識を組み合わせて、WebサイトをWebサイト内から変更するためのよりユーザーフレンドリーなインターフェースを構築します。

18章で説明されているように、HTTPリクエストを使用して、Webサイトを構成するファイルの内容を編集するためのHTMLフォームを使用し、ユーザーがサーバー上でそれらを更新できるようにします。

まず、単一のファイルのみを編集可能にします。次に、ユーザーが編集するファイルを選択できるようにします。ファイルサーバーがディレクトリを読み取るときにファイルのリストを返すという事実を使用します。

ファイルサーバーによって公開されているコードを直接操作しないでください。間違いを犯した場合、そこにあるファイルを破損する可能性があるためです。代わりに、公開されているディレクトリの外側で作業し、テスト時にそこにコピーします。

ヒントを表示...

編集中のファイルの内容を保持するために<textarea>要素を作成できます。fetchを使用したGETリクエストは、ファイルの現在の内容を取得できます。実行中のスクリプトと同じサーバー上のファイルを参照するために、https://127.0.0.1:8000/index.htmlではなく、index.htmlのような相対URLを使用できます。

次に、ユーザーがボタンをクリックしたとき(<form>要素と"submit"イベントを使用できます)、<textarea>の内容をリクエストボディとして同じURLにPUTリクエストを作成して、ファイルを保存します。

次に、URL /へのGETリクエストによって返された行を含む<option>要素を追加することにより、サーバーのトップディレクトリにあるすべてのファイルを含む<select>要素を追加できます。ユーザーが別のファイルを選択した場合(フィールドでの"change"イベント)、スクリプトはそのファイルを取得して表示する必要があります。ファイルを保存するときは、現在選択されているファイル名を使用します。