個人開発してきた自治体標準準拠システム21本+署名収集支援SaaSを、サーバー代ゼロ円で「実際に触れるデモ」として一斉公開しました。
ショーケース(全デモへのリンク集): https://civictech-portfolio.pages.dev/
- 自治体標準準拠システムの参考実装 20業務+市民ポータル(職員ログインはデモ用、市民ポータルはモックのマイナンバーカード認証で体験可)
- 出前署名(リコール等の法定署名収集を配車モデルで支援。E2EE封緘つき)の動くデモ
- 紹介LP4本(電子投票プロトタイプ/QuietPass/供養ノート/出前署名)
この記事は、その裏側——Node.js標準ライブラリだけで書いたhttpサーバを、ほぼ無改修でCloudflare Workersに載せる手順と、実際に踏んだ罠のメモです。
構成
- 静的アセット(SPA): Workersの静的アセット機能(無料・帯域課金なし)
- API:
http.createServerで書いた既存サーバをcloudflare:nodeのhttpServerHandlerでそのまま稼働 - データ: インメモリ(デモはデータ揮発が前提なのでむしろ好都合)
- LP: Cloudflare Pages(
wrangler pages deployの直接アップロード)
Node httpサーバをWorkersで動かす
2025年以降のWorkersランタイムは node:http のサーバ側APIをサポートしています。compatibility_date を 2025-09-01 以降にして nodejs_compat を有効にすると、エントリはこれだけです。
// worker.mjs
import { httpServerHandler } from "cloudflare:node";
import "./server.mjs"; // 中で server.listen(8090) している既存サーバ
export default httpServerHandler({ port: 8090 });
listen のポート番号は実ポートではなく「ルーティングキー」として扱われます。既存コードの server.listen(PORT) をそのまま活かせるのが嬉しいところ。
wrangler設定(JSONC):
{
"name": "my-demo",
"main": "apps/api/worker.mjs",
"compatibility_date": "2025-09-01",
"compatibility_flags": ["nodejs_compat"],
"assets": { "directory": "apps/web" },
"vars": { "MY_STORAGE": "memory" }
}
assets.directory に置いた静的ファイルはCDNから直接配信され、アセットに一致しないリクエストだけがworker(=既存のNode APIサーバ)に届きます。SPA+/api/*の構成ならルーティングを書く必要すらありません。
踏んだ罠と対処
罠1: /tmp はリクエストごとに消える
Workersの node:fs は仮想ファイルシステムで、書き込めるのは /tmp のみ。しかも /tmp の内容はリクエスト間で共有されません。JSONファイル保存のアプリをそのまま載せると「保存したはずのデータが次のリクエストで消えている」状態になります。
対処: ストレージ層に「メモリモード」を足しました。isolate(Workersの実行単位)のモジュールスコープ変数はリクエスト間で保持されるので、デモ用途ならこれで十分です。
const MEM = process.env.MY_STORAGE === "memory" ? new Map() : null;
function readJson(file) {
try {
return JSON.parse(MEM ? MEM.get(file) : fs.readFileSync(file, "utf8"));
} catch { return []; }
}
function writeJson(file, data) {
const text = JSON.stringify(data, null, 2);
if (MEM) MEM.set(file, text);
else fs.writeFileSync(file, text);
}
ファイルパスをそのままMapのキーに流用すると、変更が読み書きの3関数だけで済みます。isolateは不定期に破棄されるためデータは消えますが、デモバナーに「データは不定期に消去されます」と書けば仕様です。
罠2: バンドル後は import.meta.url が undefined
fileURLToPath(import.meta.url) で __dirname を作る定番イディオムは、wrangler(esbuild)でバンドルすると import.meta.url が undefined になり起動時に即死します。
const __dirname = import.meta.url
? path.dirname(fileURLToPath(import.meta.url))
: "/app"; // メモリモードではMapのキーとしか使われないので固定値でよい
罠3: オプション依存の動的importがバンドルで死ぬ
await import("pg")(DATABASE_URL設定時のみ実行される行)のような文字列リテラルの動的importは、esbuildが解決を試みて未インストールだとビルドエラーになります。specifierを変数に逃がすと静的解析対象から外れます。
const pgSpecifier = "pg";
pg = await import(pgSpecifier); // この行に到達しなければ実行時エラーも出ない
罠4: 起動時に読む必須ファイル
起動時に await readFile("catalog.json") するコードは、バンドルに同梱されないため落ちます。エントリ側でJSONを静的importしてサーバに注入する形に変えました(esbuildがJSONをバンドルしてくれる)。
import catalog from "../data/catalog.json";
const server = await createServer({ handle, catalog });
デモバナーはデプロイ時に注入する
20本のシステムのソースに手を入れる代わりに、デプロイ用ステージングスクリプトで index.html の <body> 直後にバナーを差し込みました。源泉コードは無改修・ローカル開発では何も出ません。
const html = await readFile(indexPath, "utf8");
await writeFile(indexPath, html.replace(/(<body[^>]*>)/i, `$1${BANNER}`), "utf8");
共通コア+業務別の薄い設定という構成(20業務で共通コア1つ)にしてあったので、メモリモードの実装はcoreに1回で全業務に波及し、デモ公開は1業務あたり
node tools/demo_deploy.mjs <SystemDir> <slug>
npx wrangler deploy --config .demo-stage/<slug>/wrangler.jsonc
の2コマンドになりました。18本の一括公開はループを回すだけです。
費用とコスト感
| 項目 | 費用 |
|---|---|
| Workers(22デモ) | ¥0(無料枠: 10万リクエスト/日) |
| Pages(LP5本) | ¥0(静的配信は無制限) |
| 合計 | ¥0/月 |
デモ用途なら無料枠で全く問題ありません。クレジットカード登録も不要でした。
まとめ
-
httpServerHandlerのおかげで、依存ゼロのNode httpサーバはWorkers移植がほぼ無改修 - ファイル保存だけはメモリモードへの差し替えが必要(デモなら揮発は仕様にできる)
- バンドル環境特有の罠(
import.meta.url/動的import/同梱ファイル)は定型対処で回避可能 - 「READMEを読んでもらう」より「30秒で触ってもらう」方が圧倒的に伝わる
すべての参考実装は MIT ライセンスです。デモは https://civictech-portfolio.pages.dev/ からどうぞ。