[PR] 本記事にはプロモーションが含まれています。

【入門編 第7回】本番配信 ― ビルドから自宅サーバーでの公開まで(Step 1)

TypeScript

はじめに

前回までで、単位換算ツールの画面と計算は完成しました。ただし、ここまでは Vite の開発サーバーの上で動かしていただけです。今回は Step 1 の仕上げとして、ツールをビルドし、自宅サーバーで外から使えるようにするまでを紹介します。

前提について:この回の常駐・中継・公開の手順は、常時動いている Linux サーバー(自宅の仮想マシン)を前提にしています。Windows の WSL は、Windows を閉じたりスリープしたりすると止まるため、公開用のサーバーには向きません。一方、ビルドして、Node.js のサーバーを手で起動し、curl で確かめるところまでは、WSL でも同じ手順で試せます。

進め方は、第2回の Step 0 とほぼ同じです。違うのは、画面をプログラムの中で組み立てるのではなく、ビルド済みのファイルを読み出して返すサーバーを書く点です。

公開までは、内側から外側へ、1層ずつ確かめながら進めました。サーバーの中だけで確かめられる段階で問題をつぶしておけば、外から確かめるときに「どの層がおかしいのか」で迷わずに済むからです。組み込みでいえば、ボード単体の試験を終えてから、対向機をつないだ結合試験に進むのと同じ順番です。

本番配信 ― ビルドから自宅サーバーでの公開まで(Step 1):はじめに

ファイルを返すだけのサーバーを書く

単位換算ツールの計算はブラウザの中で行うので、サーバーの仕事は、ビルド済みの画面のファイルを送り返すことだけです。Step 0 の hello と同じく、Node.js に最初から入っている node:http だけで書きました。

中心部分は次のとおりです(エラー応答を返す小さな関数などは省略しています)。

TypeScript
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);
本番配信 ― ビルドから自宅サーバーでの公開まで(Step 1):ファイルを返すだけのサーバーを書く

新しく出てきた書き方

書き方意味組み込みでいうと
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 だけに絞っています。

JSON
{
  "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 の関数の「ヘッダファイル」にあたるもので、型チェックのためだけに使います。

Bash
$ npm install -D @types/node@24

ビルドのコマンドを登録し、画面用とサーバー用を1回でビルドできるようにしました。

Bash
$ 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 の依存関係のように、どちらかが失敗すればそこで止まります。

結果は次のとおりです。

Plaintext
public  server.js  server.js.map

server.js.map は、エラーが起きたときに「元の server.ts の何行目か」を示すための対応表です。組み込みでいう、アドレスとソース行の対応を持つデバッグ情報にあたります。

手で起動して、curl で確かめる

いきなり常駐させず、まずは手で起動して、ログを目の前に流しながら確かめました。量産の書き込みの前に、デバッガをつないで動かす段階です。

ターミナルを2つ使います。1つ目でサーバーを起動します。

Bash
$ npm start

unitconv: listening on http://127.0.0.1:5002/apps/unitconv/ と表示されたら起動成功です。このターミナルはそのままにして、2つ目のターミナルで、次の7項目を確かめました。

Bash
# 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 POSTGET ではなく POST で要求する

結果は、次のとおり7項目すべて期待どおりでした。

No確認結果
1画面200、text/html; charset=utf-8、Cache-Control: no-cache
2末尾のスラッシュなし301、Location: /apps/unitconv/
3JS ファイル200、text/javascript; charset=utf-8、Cache-Control: public, max-age=31536000, immutable
4base の外404
5存在しないファイル404
6dist/public の外を狙うNot Found(パスワードファイルの中身は出ない)
7GET 以外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 のものと、名前とポート番号が違うだけです。

JavaScript
// 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 形式にした中で、このファイルだけを古い形式で書くための印です。

登録と確認は、次のとおりです。

Bash
$ npm run build                     # 最新の状態でビルド
$ pm2 start ecosystem.config.cjs    # PM2 に登録して起動
$ pm2 list                          # 状態を確認
$ ss -ltn | grep 5002               # 待ち受けを確認
$ pm2 save                          # 起動構成を保存

結果は次のとおりです(pm2 list は一部の列を省略しています)。

Plaintext
$ 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.pm2

ss の結果では、左側の 127.0.0.1:5002 が自分の待ち受けで、外から直接はつながらないことを示しています。右側の 0.0.0.0:* は相手側の欄で、「どこからの接続でも待つ」という意味の表示なので、心配はいりません。

最後の pm2 save を忘れると、サーバーを再起動したときに unitconv だけが立ち上がりません。起動構成を不揮発メモリに書き込む操作なので、毎回忘れずに実行します。

Apache の中継設定を足す

第2回で、Apache の設定を「サイト全体の設定(認証)」と「ツールごとの中継設定」の2段構えにしておきました。そのおかげで、今回足すのは、ツールごとの設定ファイル1つだけです。認証は、サイト全体の設定で /apps/ の下すべてにかかっているので、書き足す必要がありません。

手順は、Step 0 で決めた変更手順(コピー → 差分確認 → 書き戻し → 文法チェック → 反映 → 回帰テスト)のとおりに進めました。

① hello の設定から作る

hello の設定ファイルを、名前とポート番号だけ置き換えて写します。

Bash
$ 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.conf

sed は、文字列を置き換えながらファイルを写すコマンドです。diff で、違いが名前とポート番号だけであることを確かめました。できあがった設定は、次の2行です。

Plaintext
ProxyPass        "/apps/unitconv" "http://127.0.0.1:5002/apps/unitconv"
ProxyPassReverse "/apps/unitconv" "http://127.0.0.1:5002/apps/unitconv"

② 書き戻して、文法チェックと反映

Bash
$ sudo install -m 644 -o root -g root ~/work/httpd/unitconv.conf /etc/httpd/apps.d/unitconv.conf
$ sudo httpd -t
$ sudo systemctl reload httpd

install は、コピーと、権限・所有者の設定を一度に行うコマンドです。cp してから chmod や chown をする手間と、その間の中途半端な状態を避けられます。httpd -t が Syntax OK を返したことを確かめてから、reload しました。

③ Apache 経由で確かめる

外からではなく、サーバーの中から Apache(80 番)に、公開用のドメイン宛てとして問い合わせます。-H 'Host: …' で宛先のドメインを指定しています。

Bash
$ 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 認証付きで問い合わせます。結果は次のとおりです。

Plaintext
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 へ中継されて、画面が返る
301server.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 のログで確かめました。

Bash
$ pm2 logs unitconv --lines 10

実際のログ(一部)は、次のとおりです。

Plaintext
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本です。ほかの回は、シリーズの目次からどうぞ。

ほかの開発・プログラミングの記事は、開発・プログラミングメモの目次から、テーマ別に探せます。

このブログは、にほんブログ村と人気ブログランキングに参加しています。記事が役に立ったら、下のランキングのバナーのどれかを押して応援してもらえると励みになります。

ブログランキング・にほんブログ村へにほんブログ村

サーバー構築・運用ランキング プログラムランキング

TypeScript
watasoftdevをフォローする

コメント