はじめに
前回までで、単位換算ツールの画面と計算は完成しました。ただし、ここまでは Vite の開発サーバーの上で動かしていただけです。今回は Step 1 の仕上げとして、ツールをビルドし、自宅サーバーで外から使えるようにするまでを紹介します。
前提について:この回の常駐・中継・公開の手順は、常時動いている Linux サーバー(自宅の仮想マシン)を前提にしています。Windows の WSL は、Windows を閉じたりスリープしたりすると止まるため、公開用のサーバーには向きません。一方、ビルドして、Node.js のサーバーを手で起動し、curl で確かめるところまでは、WSL でも同じ手順で試せます。
進め方は、第2回の Step 0 とほぼ同じです。違うのは、画面をプログラムの中で組み立てるのではなく、ビルド済みのファイルを読み出して返すサーバーを書く点です。
公開までは、内側から外側へ、1層ずつ確かめながら進めました。サーバーの中だけで確かめられる段階で問題をつぶしておけば、外から確かめるときに「どの層がおかしいのか」で迷わずに済むからです。組み込みでいえば、ボード単体の試験を終えてから、対向機をつないだ結合試験に進むのと同じ順番です。

ファイルを返すだけのサーバーを書く
単位換算ツールの計算はブラウザの中で行うので、サーバーの仕事は、ビルド済みの画面のファイルを送り返すことだけです。Step 0 の hello と同じく、Node.js に最初から入っている node:http だけで書きました。
中心部分は次のとおりです(エラー応答を返す小さな関数などは省略しています)。
import { createServer } from "node:http";
import { readFile } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HOST = process.env.HOST ?? "127.0.0.1";
const PORT = Number(process.env.PORT ?? 5002);
const BASE = "/apps/unitconv";
// 配信するファイルの置き場所。dist/server.js から見た ./public/ = dist/public/
const PUBLIC_DIR = fileURLToPath(new URL("./public/", import.meta.url));
// 拡張子 → ファイルの種類(Content-Type)
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
};
const server = createServer(async (req, res) => {
const urlPath = new URL(req.url ?? "/", "http://localhost").pathname;
// (405、301、base の外の 404 は省略)
// 引き算: /apps/unitconv/assets/x.js → /assets/x.js
let rel = urlPath.slice(BASE.length);
if (rel.endsWith("/")) rel += "index.html";
// 境界チェック: dist/public/ の外を指していたら 404
const filePath = path.join(PUBLIC_DIR, rel);
if (!filePath.startsWith(PUBLIC_DIR)) {
sendText(res, 404, "Not Found\n");
return;
}
// ファイルを読み出して返す。なければ 404
try {
const body = await readFile(filePath);
const ext = path.extname(filePath);
res.writeHead(200, {
"Content-Type": MIME[ext] ?? "application/octet-stream",
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
});
res.end(req.method === "HEAD" ? undefined : body);
} catch {
sendText(res, 404, "Not Found\n");
}
});
server.listen(PORT, HOST);
新しく出てきた書き方
| 書き方 | 意味 | 組み込みでいうと |
|---|---|---|
import.meta.url | このファイル自身が置かれている場所 | 自分のロードアドレス |
new URL("./public/", import.meta.url) | 自分の場所から見た ./public/ | ロードアドレスからの相対位置で、データ領域を求める |
Record<string, string> | 「文字列 → 文字列」の対応表の型 | キーで引く変換テーブル |
MIME[ext] ?? "…" | 表になければ右側の値を使う | テーブルにない値のデフォルト処理 |
async / await readFile(…) | 読み出しが終わるまで待つ。その間、ほかの要求を処理できる | DMA を起動して完了割り込みを待つ(その間 CPU は別の仕事をする) |
try { … } catch { … } | 中で失敗したら catch に飛ぶ | エラーコードを見て異常系に分岐する |
境界チェックが必要な理由
悪意のある人が /apps/unitconv/../../../etc/passwd のような住所を送ると、引き算した結果が、サーバーの中の別のファイルを指してしまうおそれがあります。path.join で住所を整理したうえで、結果が dist/public/ の中に収まっているかを確かめています。C でいえば、外から受け取った添字で配列を読む前に、範囲チェックをするのと同じです。
Cache-Control の使い分け
第4回で紹介した「ファイル名のハッシュ」と組み合わせています。index.html は毎回最新かどうかを確かめさせ(no-cache)、JS はファイル名が中身ごとに変わるので、1年間保存してよい(immutable)という指示です。プログラムを直すと index.html の中の JS の名前が変わるので、途中の Cloudflare に古い JS が残っていても、新しい名前の JS が取りに行かれます。
サーバー用のビルド
server.ts は、画面用のコードとは別に、tsc でビルドします。第3回で紹介したとおり、画面用とサーバー用では使える機能が違うので、TypeScript の設定ファイルを分けています。
サーバー用の設定(tsconfig.server.json)は、Step 0 の hello とほぼ同じ内容で、対象を server.ts だけに絞っています。
{
"compilerOptions": {
"target": "ES2022",
"module": "nodenext",
"moduleResolution": "nodenext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"types": ["node"],
"sourceMap": true,
"skipLibCheck": true
},
"include": ["src/server.ts"]
}"types": ["node"] で使っている Node.js の型情報は、次のコマンドで入れました。Node.js の関数の「ヘッダファイル」にあたるもので、型チェックのためだけに使います。
$ npm install -D @types/node@24ビルドのコマンドを登録し、画面用とサーバー用を1回でビルドできるようにしました。
$ npm pkg set scripts.build:server="tsc -p tsconfig.server.json"
$ npm pkg set scripts.build="npm run build:client && npm run build:server"
$ npm pkg set scripts.start="node dist/server.js"
$ npm run build
$ ls dist行頭の $ は、一般ユーザーで実行するコマンドであることを示す記号(プロンプト)です。&& は「前が成功したら次を実行する」という意味で、make の依存関係のように、どちらかが失敗すればそこで止まります。
結果は次のとおりです。
public server.js server.js.mapserver.js.map は、エラーが起きたときに「元の server.ts の何行目か」を示すための対応表です。組み込みでいう、アドレスとソース行の対応を持つデバッグ情報にあたります。
手で起動して、curl で確かめる
いきなり常駐させず、まずは手で起動して、ログを目の前に流しながら確かめました。量産の書き込みの前に、デバッガをつないで動かす段階です。
ターミナルを2つ使います。1つ目でサーバーを起動します。
$ npm startunitconv: listening on http://127.0.0.1:5002/apps/unitconv/ と表示されたら起動成功です。このターミナルはそのままにして、2つ目のターミナルで、次の7項目を確かめました。
# No.1 画面(index.html)
$ curl -sI http://127.0.0.1:5002/apps/unitconv/
# No.2 末尾のスラッシュなし
$ curl -sI http://127.0.0.1:5002/apps/unitconv
# No.3 JS ファイル(名前は毎回変わるので、index.html から取り出す)
$ JS=$(grep -o '/apps/unitconv/assets/[^"]*\.js' dist/public/index.html)
$ curl -sI "http://127.0.0.1:5002$JS"
# No.4 base の外
$ curl -sI http://127.0.0.1:5002/
# No.5 存在しないファイル
$ curl -sI http://127.0.0.1:5002/apps/unitconv/nothing.js
# No.6 dist/public の外を狙う
$ curl -s --path-as-is http://127.0.0.1:5002/apps/unitconv/../../../etc/passwd
# No.7 GET 以外
$ curl -s -X POST http://127.0.0.1:5002/apps/unitconv/| オプション | 意味 |
|---|---|
-I | 応答の見出し(ヘッダ)だけを表示する |
$( … ) | コマンドの出力を文字列として取り込む。JS=… でシェルの変数に入れ、$JS で使う |
--path-as-is | .. を curl 側で整理せずに、そのままサーバーへ送る。攻撃者と同じ送り方を試すため |
-X POST | GET ではなく POST で要求する |
結果は、次のとおり7項目すべて期待どおりでした。
| No | 確認 | 結果 |
|---|---|---|
| 1 | 画面 | 200、text/html; charset=utf-8、Cache-Control: no-cache |
| 2 | 末尾のスラッシュなし | 301、Location: /apps/unitconv/ |
| 3 | JS ファイル | 200、text/javascript; charset=utf-8、Cache-Control: public, max-age=31536000, immutable |
| 4 | base の外 | 404 |
| 5 | 存在しないファイル | 404 |
| 6 | dist/public の外を狙う | Not Found(パスワードファイルの中身は出ない) |
| 7 | GET 以外 | Method Not Allowed |
No.6 は、実は境界チェックまで届いていません。サーバーの中で new URL() が .. を先に整理して /etc/passwd にしたため、「base の外」のチェックで 404 になっています。それでも境界チェックを残しているのは、別の書き方で .. が紛れ込んだときの二重の守りのためです。上位層で範囲チェックしていても、ドライバ層でももう一度チェックしておくのと同じ考え方です。
ブラウザでも、VS Code のポート転送で 5002 番を手元の PC につなぎ、http://localhost:5002/apps/unitconv/ で開いて、換算と入力チェックが動くことを確かめました。確認が終わったら、1つ目のターミナルで Ctrl + C を押してサーバーを止めます。
PM2 で常駐させる
手で確かめられたら、第2回で紹介した PM2 に登録して常駐させます。起動の設定ファイルは、hello のものと、名前とポート番号が違うだけです。
// PM2 起動設定
module.exports = {
apps: [
{
name: "unitconv",
script: "dist/server.js",
cwd: __dirname, // このファイルがあるフォルダで起動する
autorestart: true, // 異常終了したら自動で再起動
env: {
NODE_ENV: "production",
HOST: "127.0.0.1", // 自分自身からの接続だけ受け付ける
PORT: "5002",
},
},
],
};env に書いた値は、server.ts の process.env.HOST や process.env.PORT で読まれます。ファームは同じまま、ディップスイッチで動作を変えるような仕組みです。ファイル名の拡張子が .cjs なのは、プロジェクト全体を新しい import 形式にした中で、このファイルだけを古い形式で書くための印です。
登録と確認は、次のとおりです。
$ npm run build # 最新の状態でビルド
$ pm2 start ecosystem.config.cjs # PM2 に登録して起動
$ pm2 list # 状態を確認
$ ss -ltn | grep 5002 # 待ち受けを確認
$ pm2 save # 起動構成を保存結果は次のとおりです(pm2 list は一部の列を省略しています)。
$ pm2 list
│ id │ name │ ↺ │ status │ user │
│ 0 │ hello │ 0 │ online │ devuser │
│ 1 │ unitconv │ 0 │ online │ devuser │
$ ss -ltn | grep 5002
LISTEN 0 511 127.0.0.1:5002 0.0.0.0:*
$ pm2 save
[PM2] Saving current process list...
[PM2] Successfully saved in /home/devuser/.pm2/dump.pm2ss の結果では、左側の 127.0.0.1:5002 が自分の待ち受けで、外から直接はつながらないことを示しています。右側の 0.0.0.0:* は相手側の欄で、「どこからの接続でも待つ」という意味の表示なので、心配はいりません。
最後の pm2 save を忘れると、サーバーを再起動したときに unitconv だけが立ち上がりません。起動構成を不揮発メモリに書き込む操作なので、毎回忘れずに実行します。
Apache の中継設定を足す
第2回で、Apache の設定を「サイト全体の設定(認証)」と「ツールごとの中継設定」の2段構えにしておきました。そのおかげで、今回足すのは、ツールごとの設定ファイル1つだけです。認証は、サイト全体の設定で /apps/ の下すべてにかかっているので、書き足す必要がありません。
手順は、Step 0 で決めた変更手順(コピー → 差分確認 → 書き戻し → 文法チェック → 反映 → 回帰テスト)のとおりに進めました。
① hello の設定から作る
hello の設定ファイルを、名前とポート番号だけ置き換えて写します。
$ sed -e 's/hello/unitconv/g' -e 's/5001/5002/g' /etc/httpd/apps.d/hello.conf > ~/work/httpd/unitconv.conf
$ diff /etc/httpd/apps.d/hello.conf ~/work/httpd/unitconv.conf
$ cat ~/work/httpd/unitconv.confsed は、文字列を置き換えながらファイルを写すコマンドです。diff で、違いが名前とポート番号だけであることを確かめました。できあがった設定は、次の2行です。
ProxyPass "/apps/unitconv" "http://127.0.0.1:5002/apps/unitconv"
ProxyPassReverse "/apps/unitconv" "http://127.0.0.1:5002/apps/unitconv"② 書き戻して、文法チェックと反映
$ sudo install -m 644 -o root -g root ~/work/httpd/unitconv.conf /etc/httpd/apps.d/unitconv.conf
$ sudo httpd -t
$ sudo systemctl reload httpdinstall は、コピーと、権限・所有者の設定を一度に行うコマンドです。cp してから chmod や chown をする手間と、その間の中途半端な状態を避けられます。httpd -t が Syntax OK を返したことを確かめてから、reload しました。
③ Apache 経由で確かめる
外からではなく、サーバーの中から Apache(80 番)に、公開用のドメイン宛てとして問い合わせます。-H 'Host: …' で宛先のドメインを指定しています。
$ curl -sI -H 'Host: example.com' http://127.0.0.1/apps/unitconv/ | head -1
$ curl -sI -u devuser -H 'Host: example.com' http://127.0.0.1/apps/unitconv/ | head -1
$ curl -sI -u devuser -H 'Host: example.com' http://127.0.0.1/apps/unitconv | grep -i -e '^HTTP' -e '^Location'-u devuser を付けると、パスワードを聞かれて、Basic 認証付きで問い合わせます。結果は次のとおりです。
HTTP/1.1 401 Unauthorized
HTTP/1.1 200 OK
HTTP/1.1 301 Moved Permanently
Location: /apps/unitconv/| 結果 | 意味 |
|---|---|
| 401 | 認証なしでは見られない。認証の設定が新しいツールにも効いている |
| 200 | 認証すれば、Apache から 5002 番の Node.js へ中継されて、画面が返る |
| 301 | server.js が返した転送が、Apache を通っても正しく届く |
④ 回帰テスト
最後に、自作の確認スクリプトで、ほかのサイトやパスに影響が出ていないことを確かめました。スクリプトは、宛先のドメインとパスを並べた一覧を読み、それぞれのステータスコードを表示するものです。一覧に unitconv の行を1行足して実行し、新しい行が 401(認証が必要)、ほかの行が以前と同じ値であることを確認しました。
設定を1つ足すたびに、全体が壊れていないかを確かめる。組み込みでいう、機能追加のたびに既存の試験項目を流し直す回帰試験と同じです。
外から確かめる
最後に、インターネット側から、Cloudflare → Apache → Node.js の本番の経路を通して確かめました。
| 確認 | 方法 | 結果 |
|---|---|---|
| 認証の後に表示される | PC のブラウザで https://example.com/apps/unitconv/ を開く | ユーザー名とパスワードを聞かれ、入力すると画面が出る |
| 換算と入力チェック | 血糖 100、クレアチニン 1、abc を入れる | 5.55 mmol/L、88.40 µmol/L、メッセージ |
| スラッシュなしの URL | 末尾の / を付けずに開く | https:// のまま /apps/unitconv/ に切り替わる |
| スマホの実機 | モバイル回線で開いて換算する | はみ出さず、数字のキーボードで入力できる |
ログで、要求が届いたことを確かめる
ブラウザで開いたときの要求が、本当に Node.js まで届いているかを、PM2 のログで確かめました。
$ pm2 logs unitconv --lines 10実際のログ(一部)は、次のとおりです。
2026-10-05T07:21:48.166Z GET /apps/unitconv/
2026-10-05T07:21:48.322Z GET /apps/unitconv/assets/index-CDQylnpc.js
2026-10-05T07:24:49.239Z GET /apps/unitconv
2026-10-05T07:24:49.282Z GET /apps/unitconv/ここから、次の3つが読み取れます。
| ログ | 読み取れること |
|---|---|
| 1・2行目 | 画面と JS が、外部の経路を通って Node.js に届いた |
| 3・4行目 | スラッシュなしの要求 → 301 → スラッシュ付きの要求、の2往復 |
| 4行目の後に JS の要求がない | 2回目以降は、ブラウザが手元に保存した JS をそのまま使っている |
最後の点は、前の節で設定した Cache-Control が、設計どおりに働いている証拠です。index.html は no-cache なので毎回確かめに来ていますが、JS は immutable なので取りに来ていません。
なお、時刻の末尾の Z は世界標準時(UTC)を表します。日本時間は 9 時間進んでいるので、07:21 は日本時間の 16:21 です。組み込みでいえば、ログのタイムスタンプを、RTC のローカル時刻ではなく UTC で記録しているのと同じです。
まとめと次回予告
これで Step 1 の単位換算ツールは、要件定義書に書いた受け入れ条件をすべて満たしました。
| 受け入れ条件 | 結果 |
|---|---|
| 決められた URL で、認証の後に画面が表示される | 満たした |
| 4項目それぞれで、両方向の換算が手計算と一致する | 満たした |
| 異常な入力でメッセージが出て、エラーで止まらない | 満たした |
振り返ってみると、公開の作業そのものは、Step 0 の手順をほぼそのまま使い回せました。PM2 の設定はファイルを写して名前とポートを変えるだけ、Apache の設定は1ファイル足すだけで、認証も変更手順も Step 0 で作ったものがそのまま効いています。最初に手間をかけて「型」を作っておくと、2つ目からは速く、安全に進められる。組み込みで、最初の製品で作ったビルド環境や試験手順を、後継機種で使い回すのと同じ感覚でした。
新しく覚えたのは、ビルド済みのファイルを返すサーバーの書き方と、Cache-Control の使い分けです。どちらも、第4回で見た要求の流れとファイル名のハッシュの仕組みに、そのままつながっています。
次回は、このシリーズの Step 1 までの締めくくりとして、振り返りを書きます。つまずいたこと、新しく覚えたこと、そして Web の概念と組み込みの対応表を、1つにまとめる予定です。
この記事は、シリーズ「組み込みエンジニアの Web 開発入門」の1本です。ほかの回は、シリーズの目次からどうぞ。



コメント