SPA を AWS 上で動かす構成では、認証、オブジェクトストレージ、メール配送、イベント配信など、多くの外部サービスが実行経路に入ります。ローカル開発でもそれらをすべて再現しようとすると、アプリケーションを動かす前に外部サービス側の準備が必要になります。
spa-reference では、この問題に対して AWS API のローカル模倣を作るのではなく、アプリケーションが依存する外部アダプターだけをローカル実装へ交換しています。ローカルでも React、NestJS、PostgreSQL、認可、状態遷移、監査、Transactional Outbox は同じ実装を通り、Cognito、S3、SES、SNS に相当する部分だけが差し替わります[1]。
この記事では、その実装を npm run demo の起動経路から順に追います。
実行環境をそろえる
ローカルデモの前提は Node.js 24 と Docker です。Docker 側は Docker Engine、Docker CLI、Docker Compose v2 が必要です[2]。
リポジトリを取得したら、最初の操作は次の 3 つです。
git clone https://github.com/id774/spa-reference.git
cd spa-reference
npm ci
npm run demo
npm run demo が利用するポートは次のとおりです。
| 用途 | アドレス |
|---|---|
| フロントエンド | 127.0.0.1:5173 |
| バックエンド | 127.0.0.1:3000 |
| PostgreSQL | 127.0.0.1:55432 |
起動に成功すると、最後に次のメッセージが表示されます。
Local demo is ready.
SPA:
http://127.0.0.1:5173/
ここで重要なのは、プロセスを起動した時点を成功とみなしていないことです。バックエンドとフロントエンドが実際に応答するところまで確認してから起動完了状態へ進みます。
npm run demo が起動前に確認するもの
package.json の demo は scripts/demo.mjs run を実行します[1]。
{
"scripts": {
"demo": "node scripts/demo.mjs run",
"demo:setup": "node scripts/demo.mjs setup",
"demo:backend": "node scripts/demo.mjs backend",
"demo:down": "node scripts/demo.mjs down",
"demo:reset": "node scripts/demo.mjs reset",
"demo:deliveries": "node scripts/demo-deliveries.mjs"
}
}
demo.mjs は、起動処理へ入る前に Node.js、Docker、Docker Compose、Docker デーモン、使用ポートを確認します[3]。
const REQUIRED_NODE_MAJOR = 24;
const HOST = '127.0.0.1';
const PORTS = { frontend: 5173, backend: 3000, postgres: 55432 };
function checkNode() {
const major = Number(process.versions.node.split('.')[0]);
if (major !== REQUIRED_NODE_MAJOR) {
fail(
`Local demo cannot start: Node.js ${REQUIRED_NODE_MAJOR} is required.\n` +
` expected: Node.js ${REQUIRED_NODE_MAJOR}.x\n` +
` actual: Node.js ${process.versions.node}`,
);
}
}
function checkDocker() {
const version = dockerSync(['--version']);
if (version.error?.code === 'ENOENT') {
fail('Local demo cannot start: the `docker` command was not found.');
}
const compose = dockerSync(['compose', 'version']);
if (compose.error || compose.status !== 0) {
fail('Local demo cannot start: Docker Compose v2 (`docker compose`) is not available.');
}
const info = dockerSync(['info']);
if (info.status !== 0) {
fail('Docker is installed but the Docker daemon is not running.');
}
}
Node.js は単に 24 以上ではなく、ローカルデモでは メジャーバージョンが 24 と一致することを要求します。Docker もコマンドの存在確認だけではなく、Compose v2 とデーモンへの接続まで確認しています。
ポートも起動前に検査します。前回のデモで PostgreSQL が起動したままの場合だけは、その PostgreSQL が自分のポートを保持している状態を許容します。それ以外の競合は起動前にエラーにします。
この事前確認により、Node.js、Docker、ポート競合などの起動前条件を個別に検出できます。
PostgreSQL 17 を起動してマイグレーションを適用する
ローカルモードでもデータベースは PostgreSQL のままです。compose.yaml では PostgreSQL 17 を 127.0.0.1:55432 に公開し、名前付きボリュームとヘルスチェックを設定しています[4]。
services:
postgres:
image: postgres:17
environment:
POSTGRES_DB: spa_reference
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
ports:
- '127.0.0.1:55432:5432'
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U postgres -d spa_reference']
interval: 2s
timeout: 3s
retries: 30
demo.mjs は docker compose up に --wait を付け、PostgreSQL のヘルスチェックが通るまで待ちます。その後に Prisma Client を生成し、コミット済み migration を適用します[3]。
function setup() {
step(
'Starting local PostgreSQL (waiting until healthy)',
docker,
['compose', 'up', '-d', '--wait', '--wait-timeout', '90', 'postgres'],
);
step('Generating the Prisma client', npm, ['run', 'prisma:generate']);
step('Applying database migrations', npm, ['run', 'migrate:deploy'], {
env: backendEnv,
});
}
ローカル用の接続先は次の値です。
postgresql://postgres:postgres@127.0.0.1:55432/spa_reference
Prisma の migrate deploy は未適用のマイグレーションを適用するコマンドです。スキーマ差分の検査、データベースのリセット、生成物の作成は担当しません[5]。そのため spa-reference では Prisma Client 生成とマイグレーション適用を別の工程として実行しています。
ローカルでも PostgreSQL を変えない理由
ローカル実行を軽くするだけなら SQLite やインメモリ DB へ置き換える方法もあります。しかし、このアプリケーションでは PostgreSQL のトランザクション、行ロック、楽観的同時実行制御、マイグレーション、Transactional Outbox が業務処理に関係します。
ローカルと AWS でデータベースの意味論まで変えると、画面操作は再現できても、状態更新時の競合やトランザクション境界は別の条件になります。
spa-reference は、ローカルでも PostgreSQL を使うことで、データモデル、トランザクション境界、行ロックなど PostgreSQL に依存する業務処理を共通の実装で通します[6]。
APP_MODE で外部アダプターだけを切り替える
ローカルと AWS の分岐は backend/src/adapters.ts に集約されています[7]。
export function createAdapters(config: AppConfig, logger: AppLogger): ExternalAdapters {
if (config.mode === 'local') {
const dataDir = config.local.dataDir;
return {
identity: new LocalIdentityProvider(),
storage: new LocalObjectStorage(dataDir),
mail: new LocalMailSender(dataDir),
events: new LocalEventPublisher(dataDir),
};
}
const region = config.aws.region;
return {
identity: new CognitoIdentityProvider(config.cognito, logger),
storage: new S3ObjectStorage(new S3Client({ region }), config.s3.bucket),
mail: new SesMailSender(new SESv2Client({ region }), config.ses.sender),
events: new SnsEventPublisher(new SNSClient({ region }), config.sns.topicArn),
};
}
切り替えている責務は 4 つです。
| 責務 | ローカル | AWS |
|---|---|---|
| 認証 | LocalIdentityProvider |
Cognito |
| オブジェクトストレージ | LocalObjectStorage |
S3 |
| メール | LocalMailSender |
SES |
| イベント | LocalEventPublisher |
SNS |
AWS SDK のクライアントを生成するのは AWS モードだけです。ローカルモードでは AWS SDK の呼び出し経路自体が作られません。
この分岐をアダプター生成時の 1 か所に置くことで、申請作成、承認、監査などのアプリケーションサービス側に if (APP_MODE === 'local') を増やさずに済みます。基本設計でも、モードによって変えるのは外部インフラストラクチャアダプターであり、認可、ドメイン処理、永続化、監査、outbox の意味論は共有すると定義されています[6]。
Cognito を固定デモ identity に置き換える
ローカル認証は LocalIdentityProvider が担当します。受け付ける Bearer トークンは 3 種類だけです[8]。
export const DEMO_IDENTITIES = {
'demo-requester': {
subject: 'local-requester',
role: 'Requester',
email: 'requester@example.test',
},
'demo-approver': {
subject: 'local-approver',
role: 'Approver',
email: 'approver@example.test',
},
'demo-administrator': {
subject: 'local-administrator',
role: 'Administrator',
email: 'administrator@example.test',
},
};
この token は開発用の固定値であり、資格情報としての秘密性を持ちません。APP_MODE=local のバックエンドだけが受け付け、AWS モードでは Cognito 側の認証アダプターが選択されます[2]。
ここでローカル専用にしているのは本人確認の手段です。業務上の認可は同じバックエンド処理を通ります。Requester、Approver、Administrator のロールごとに許可される操作を確認するために、外部 IdP だけを固定 ID 情報へ置き換えています。
S3 をローカルファイルへ置き換える
添付ファイルは LocalObjectStorage が .local/attachments/ 以下へ保存します[8]。
export class LocalObjectStorage implements ObjectStorage {
private readonly root: string;
constructor(dataDir: string) {
this.root = resolve(dataDir, 'attachments');
}
async put(key: string, body: Buffer, _contentType: string): Promise<void> {
const path = this.pathFor(key);
await mkdir(dirname(path), { recursive: true });
await writeFile(path, body);
}
}
S3 とローカルファイルで共通に使う保存キーは次の形です。
attachments/<requestId>/<attachmentId>
LocalObjectStorage は保存キーを attachments/<requestId>/<attachmentId> の形式に制限し、セグメント数、許可文字、.. の混入、解決後パスが保存ルート内に収まることを検査します。
private pathFor(key: string): string {
const segments = key.split('/');
const expected = segments.length === 3 && segments[0] === 'attachments';
if (
!expected ||
!segments.every((segment) => SAFE_SEGMENT.test(segment) && !segment.includes('..'))
) {
throw new Error('invalid object key');
}
const path = resolve(this.root, ...segments.slice(1));
if (!path.startsWith(this.root + sep)) throw new Error('invalid object key');
return path;
}
アプリケーション側は ObjectStorage という境界だけを使います。保存先を S3 からローカルファイルへ変えても、添付ファイルを扱う業務サービス側の責務は変わりません。
SES と SNS を NDJSON 記録へ置き換える
メールとイベントも、ローカルモードでは LocalMailSender と LocalEventPublisher が NDJSON へ記録します[8]。
export class LocalMailSender implements MailSender {
private readonly file: string;
constructor(dataDir: string) {
this.file = resolve(dataDir, 'deliveries', 'email.ndjson');
}
async send(message: MailMessage): Promise<void> {
await append(this.file, {
timestamp: new Date().toISOString(),
to: message.to,
subject: message.subject,
text: message.text,
});
}
}
イベント側も同じ考え方で .local/deliveries/events.ndjson へ追記します。
ローカルでも outbox ワーカーを動かし、最終的な配送先だけを SES/SNS からローカル記録アダプターへ切り替えます[8]。
そのため、submit や approve の後で配送記録を確認できます。
npm run demo:deliveries
このコマンドで、記録されたメールとイベントを読めます[2]。
業務サービスはローカルと AWS で分岐させない
外部サービスの切り替えをアダプター境界へ閉じ込めると、業務サービスは実行環境を意識せずに依存先を利用できます。
現在のバックエンドでは Requests、Approvals、Attachments、Audit を同一プロセス内の機能として組み立てています。ローカルと AWS で変わるのは 認証、オブジェクトストレージ、メール、イベントの実装です。アプリケーション側の認可順序、状態遷移、PostgreSQL 永続化、監査、outbox は共通です[6]。
この構造によって、ローカルデモ用の条件分岐が業務コードへ広がることを防いでいます。たとえば申請承認処理の中で APP_MODE を確認して SNS かファイル出力かを選ぶ必要はありません。承認処理はイベント配送を要求し、実際の配送先はアダプター生成時に決まります。
フロントエンドとバックエンドが応答してから起動完了にする
demo.mjs はバックエンドとフロントエンドを起動した後、それぞれの応答を待ちます[3]。
バックエンドの確認先は次です。
http://127.0.0.1:3000/health/ready
フロントエンドは次です。
http://127.0.0.1:5173/
バックエンドは HTTP 200 に加えて JSON の status が ok であることまで確認します。
await waitUntil(
`the backend (${BACKEND_READY_URL})`,
() => httpOk(
BACKEND_READY_URL,
async (response) => (await response.json()).status === 'ok',
),
[backend],
'Check the backend log above.',
);
await waitUntil(
`the frontend (${SPA_URL})`,
() => httpOk(SPA_URL),
[frontend],
'Check the frontend output above.',
);
起動待ちの上限は 120 秒、HTTP 疎通確認のタイムアウト は 2 秒、再試行間隔は 500 ms です。PostgreSQL の起動待ちは別に 90 秒を設定しています。
この処理が終わった後にだけ Local demo is ready. を表示します。
片方のプロセスが停止したらもう片方も終了する
起動後も、フロントエンドとバックエンドの一方が終了すると、もう一方も停止します[3]。
Unix 系では子プロセスを プロセスグループとして起動し、終了時に SIGTERM を送ります。5 秒以内に終了しなかった場合は SIGKILL へ進みます。
ローカルデモで半分だけ動作している状態を残さないための処理です。
ローカルで業務フローを確認する
起動後は http://127.0.0.1:5173/ を開きます。
最初の画面には次の 3 つの選択肢があります。
- Continue as Requester
- Continue as Approver
- Continue as Administrator
確認する流れは次です[2]。
- Requester で申請を作成します。
-
DRAFTの申請を編集します。 - 添付ファイルを追加します。
- 申請を submit して
SUBMITTEDにします。 - Approver へ切り替え、Approval Queue から申請を開きます。
- approve して
APPROVEDにします。 - Administrator へ切り替え、監査履歴を確認します。
-
npm run demo:deliveriesでメールとイベントの配送記録を確認します。
この一連の操作で、画面表示だけでなく、認可、状態遷移、PostgreSQL 永続化、監査、outbox、添付ファイル、配送アダプターまで同時に通ります。
停止とリセットを使い分ける
デモを止める操作は、残す状態によって分かれています[2]。
| 操作 | 結果 |
|---|---|
Ctrl+C |
フロントエンドとバックエンドを停止します。PostgreSQL とデータは残ります。 |
npm run demo:down |
PostgreSQL コンテナ を停止・削除します。名前付きボリュームは残ります。 |
npm run demo:reset |
PostgreSQL ボリューム、.local/attachments/、.local/deliveries/ を削除します。 |
PostgreSQL の名前付きボリューム は spa-reference-demo_postgres-data です。
demo:reset はフロントエンドまたはバックエンドのポートが使用中の場合は実行を拒否します。デモが動作したままデータだけ消す状態を避けるためです。
ローカル実行で確認できる範囲を分ける
ローカルデモが確認するのは、アプリケーションがローカルアダプターと PostgreSQL を使って成立する範囲です。
| ローカルデモで確認できるもの | AWS 上で確認するもの |
|---|---|
| React SPA | Cognito |
| NestJS BFF/backend | S3 |
| バックエンド認可 | SES |
| 業務状態遷移 | SNS |
| PostgreSQL 永続化 | Aurora |
| migration | ECS/Fargate |
| 添付ファイルの ObjectStorage 境界 | ALB |
| Transactional Outbox | IAM |
| 監査 | Secrets Manager |
| ローカルメール・イベント配送 | AWS デプロイ構成 |
Milestone 1: Local demo と Milestone 3: AWS deployment and full demo は検証対象が異なります[2]。
ローカルデモが成功しても Cognito や S3 の統合まで確認したことにはなりません。一方で、AWS を用意する前にアプリケーション側の主要な業務フローを最後まで実行できます。
まとめ
spa-reference のローカル実行では、APP_MODE=local で認証、オブジェクトストレージ、メール、イベントの外部アダプターだけを交換し、React、NestJS、PostgreSQL、認可、状態遷移、監査、Transactional Outbox を共通実装のまま動かします。
そのうえで npm run demo が前提条件、PostgreSQL、migration、frontend、backend、readiness までを一つの実行経路として管理します。ローカル実装を簡易版として別系統にするのではなく、本番と共有する業務意味論を実行する経路として使う構成です。
この実装は、参照実装に設計判断とその検証経路を残すという spa-reference の方針を、ローカル開発環境にも反映したものです[9]。
参考文献
- id774, spa-reference README. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/README.md
- id774, Getting started. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/doc/GETTING_STARTED.md
- id774, spa-reference
scripts/demo.mjs. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/scripts/demo.mjs - id774, spa-reference
compose.yaml. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/compose.yaml - Prisma, prisma migrate deploy | Apply Migrations to Production (Prisma CLI v7). https://www.prisma.io/docs/cli/v7/migrate/deploy
- id774, Basic design: an SPA development reference application. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/doc/BASIC_DESIGN.md
- id774, spa-reference
backend/src/adapters.ts. https://github.com/id774/spa-reference/blob/28905244d51908a32a47cbefd3be4855df0cea13/backend/src/adapters.ts - id774, spa-reference local infrastructure adapters. https://github.com/id774/spa-reference/tree/28905244d51908a32a47cbefd3be4855df0cea13/backend/src/infrastructure/local
- id774, 参照実装は設計判断を保存する(2026-10-05). https://blog.id774.net/entry/2026/10/05/5751/