3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AWS Blocksを組み合わせてチャットアプリを作ってみた

3
Posted at

目次

AWS Blocksとは?

AWSが提供するオープンソースのTypeScriptフレームワークで、フルスタックアプリケーションを構築するためのバックエンドツールキットと位置付けられています。Blockとして提供されるモジュールを組み合わせることで、簡単にバックエンドを構築できます。

AWS Blocksの利点として以下が挙げられています。

  1. 数秒で構築を開始:コマンドを切り替えることでローカル実行、サンドボックスデプロイ、本番デプロイが可能
  2. エンドツーエンド型安全性:データスキーマを定義すれば、フロントエンドの型も自動更新
  3. コーディングエージェント向けのステアリングファイルも提供:AGENTS.mdがテンプレートに含まれる
  4. 構築できるものに上限はない:CDKや設定を直接変更し、チューニングすることも可能
  5. 既存システムに追加可能:Blockを追加することで、既存システムの拡張も可能

利点の1つめである「数秒で構築を開始」について少し解説します。AWS Blocksでは、実装したシステムのローカルテスト、AWSへのデプロイが、コマンドの切り替えのみで可能です。言い換えると開発者はコードを変更する必要がありません。そのため、ローカル環境で動作確認したコードを、そのままAWS上で実行できます。開発環境と本番環境で実装を分ける必要がなく、環境差異による不具合を減らせます。

AWS Blocksは、ローカルへのデプロイ、サンドボックスへのデプロイ、本番環境としてのデプロイの3方式が提供されています。それぞれバックエンドとフロントエンドが動作する場所が異なります。

デプロイ方式 バックエンド フロントエンド
ローカルへのデプロイ ローカル環境 ローカル環境
サンドボックスとしてデプロイ AWS ローカル環境
本番環境としてのデプロイ AWS AWS

このAWS Blocksで簡単なアプリケーションを実装し、ローカル⇒サンドボックス⇒本番環境と順にデプロイしていきたいと思います。

記事作成時点では、AWS BlocksはPreviewとなっています。今後大きな変更が入る可能性も考慮してください。

AWS Blocksでチャットアプリを作ってみる

開発環境

今回は@aws-blocks/blocks のバージョン0.2.3を利用しています。また紹介するコードはClaude Codeで生成しています。プロジェクト一式はGitHubにも公開しているので、そちらも参照してください。またOSはWindowsを用いています。テストコードに一部OS依存の実装があるため注意してください。またセキュリティ対応なども不完全なため、あくまで参考レベルであることはご承知おきください。

全体概略図

今回はBedrockを用いたチャットアプリを作成します。フロントエンドをWebアプリケーションとして提供し、そこからLLMと会話します。WebアプリケーションにはCognitoを用いた認証機能も設けます。

今回作成するシステムの概要図です。フロントエンドをCloudFrontで提供します。フロントエンドからはAPI Gatewayを通してLambdaで処理します。

image.png

プロジェクトの新規作成

はじめに、次のコマンドでプロジェクトを作成します。オプションとしてbareテンプレートを指定しています。bareは最小構成のテンプレートです。

npm create @aws-blocks/blocks-app@latest my-app -- --template bare

AWS Blocksではdefaultをはじめ、nextjsやauth-cognitoなどのサンプルテンプレートが提供されています。

このときのディレクトリ構成は次のようになります。

block-agent/
├── .blocks/
│   └── config.json
├── .gitignore
├── AGENTS.md
├── CLAUDE.md
├── README.md
├── aws-blocks/
│   ├── index.cdk.ts
│   ├── index.handler.ts
│   ├── index.ts
│   ├── package.json
│   └── scripts/
│       ├── cleanup.ts
│       ├── console.ts
│       ├── deploy.ts
│       ├── destroy.ts
│       ├── sandbox-destroy.ts
│       ├── sandbox.ts
│       └── server.ts
├── cdk.json
├── index.html
├── package-lock.json
├── package.json
├── src/
│   └── index.ts
├── test/
│   └── e2e.test.ts
├── tsconfig.json
└── vite.config.ts

今回は以下のファイルを変更します。

ファイルパス 位置付け
aws-blocks\index.ts バックエンド
src\index.ts フロントエンド
test/e2e.test.ts テストスクリプト(バックエンド)

バックエンドの作成

まずはバックエンドから実装していきます。更新対象はaws-blocks\index.ts です。

aws-blocks\index.ts の前半部分ではBlocksを組み合わせて必要な機能を組み上げていきます。

import { ApiNamespace, Scope, AuthCognito, Agent, BedrockModels } from '@aws-blocks/blocks';

はじめに必要なブロックをimportします。ApiNamespaceはAWS上ではバックエンドからフロントエンド向けに公開されるAPIを定義するためのブロックです。ローカル環境ではdevサーバで処理され、AWS上ではAPI Gatewayとしてデプロイされます。Scopeはバックエンドの境界を定義します。AuthCognitoはCognitoを用いたユーザー認証に利用します。AIエージェント向けにはAgentをインポートします。また事前定義済みモデルを利用するためBedrockModelsもインポートします。

const scope = new Scope('my-app');

const auth = new AuthCognito(scope, 'auth', {
  authFlowType: 'USER_PASSWORD_AUTH',
  passwordPolicy: { minLength: 8 },
  signInWith: ['username', 'email'],
  crossDomain: process.env.BLOCKS_SANDBOX === 'true',
});
export const authApi = auth.createApi();

AuthCognitoでは、下記表の項目を引数で設定しています。これ以外にもMFA有効やセルフサインアップの有効無効などCognitoに備わっている機能を設定できます。

属性名 内容 設定項目
authFlowType 認証フロー 'USER_PASSWORD_AUTH'でユーザー名とパスワードを用いた認証
passwordPolicy パスワードポリシー 最小8文字を指定。明示していないがデフォルトオプションとして大文字/小文字/数字/記号が1文字以上必須と設定されている。
signInWith サインインで、ユーザーIDとして利用可能なもの ユーザー名とメールアドレス
crossDomain 異なるドメイン間で認証Cookieを送信するか サンドボックスのみ有効
※サンドボックスではフロントエンドがローカル、バックエンドがAWSとなるため
const agent = new Agent(scope, 'chat', {
  model: { deployed: BedrockModels.BALANCED },
  systemPrompt: 'You are a helpful assistant. Answer concisely.',
});

AIエージェントを定義します。今回は単なるチャットボットを作成するため、モデルとシステムプロンプトのみを指定しています。BedrockModels.BALANCEDは精度とコストのバランスを重視したモデルプリセットです。今回使用したバージョンでは、Claude Sonnet 4.6が設定されています。

BALANCED以外にもSMARTとFASTがあり、今回使用したバージョンでは、それぞれClaude Opus 4.8とClaude Haiku 4.5が設定されています。プリセットが参照するモデルは今後変更される可能性が高いため、注意が必要です。

export const api = new ApiNamespace(scope, 'api', (context) => ({
  async createConversation() {
    const user = await auth.requireAuth(context);
    const conversationId = await agent.createConversationId(user.username);
    return { conversationId };
  },

  async sendMessage(conversationId: string, message: string, channelId: string) {
    const user = await auth.requireAuth(context);
    await assertOwnsConversation(user.username, conversationId);
    await agent.stream(message, { conversationId, channelId, userId: user.username });
  },

  async getConversation(conversationId: string) {
    const user = await auth.requireAuth(context);
    await assertOwnsConversation(user.username, conversationId);
    const messages = await agent.getConversation(conversationId);
    return { messages };
  },

  async getChannel(channelId: string) {
    await auth.requireAuth(context);
    return agent.getChannel(channelId);
  },

  async listConversations() {
    const user = await auth.requireAuth(context);
    return await agent.listConversations(user.username);
  },

  async resume(
    channelId: string,
    responses: Array<{ interruptId: string; approved: boolean; trust?: boolean }>,
    conversationId: string,
  ) {
    const user = await auth.requireAuth(context);
    await assertOwnsConversation(user.username, conversationId);
    await agent.resume(channelId, responses, { conversationId, userId: user.username });
  },
}));

async function assertOwnsConversation(userId: string, conversationId: string) {
  const owned = await agent.listConversations(userId);
  if (!owned.some((c) => c.conversationId === conversationId)) {
    throw new Error('Conversation not found');
  }
}

最後に、フロントエンドに公開するAPIを定義します。基本的にAgentブロックが提供するAPIを呼び出します。ログインユーザー情報が必要な場合は、AuthCognitoブロックのrequireAuthで取得します。

メッセージを送信するsendMessageと会話を取得するgetConversationでは、assertOwnsConversation でconversationIdの持ち主が、userIdと一致するかを検証しています。conversationId はUUIDなので、一致する可能性はかなり低いですが、公式でも以下のように注意喚起されています。

Authorization (caller responsibility)
The Agent BB scopes data by conversationId, which is an unguessable UUID,
but it does not authorize the caller against a conversation on read paths.
getConversation(id) and getPendingInterrupts(conversationId) take only an id,
so any caller that supplies a valid conversation ID gets the messages back.
Your API handler owns authorization: derive userId from the authenticated session
and verify the conversation belongs to that user before reading it.
listConversations(userId) returns only the conversations a user owns,
so it's the safe way to resolve which conversation IDs a caller may access:

テストコードの実装

test/e2e.test.tsを実装します。E2Eとついていますが、バックエンドから提供されているAPIを呼び出してテストします。生成されたプロジェクトのREADMEにも

Testing is best done through the e2e tests which use the same typed client as the frontend.

と記載されており、バックエンドが提供するAPIが正しく動作するかを確認するテストです。

import { test } from 'node:test';
import assert from 'node:assert';
import { spawn, type ChildProcess } from 'node:child_process';
import { setTimeout } from 'node:timers/promises';
import { installCookieJar, isServerRunning } from '@aws-blocks/blocks/utils';
import type { api as apiType, authApi as authApiType } from 'aws-blocks';

installCookieJar();

必要なパッケージをimportします。installCookieJar は、サーバから返る Cookie を保存し、次のリクエストで自動的に送るための Cookie ストアを Node の fetch に組み込む関数です。

let server: ChildProcess | null = null;
let api: typeof apiType;
let authApi: typeof authApiType;

test.before(async () => {
  if (!await isServerRunning()) {
    server = spawn('npm.cmd', ['run', 'dev:server'], {
      cwd: process.cwd(),
      stdio: ['ignore', 'pipe', 'pipe'],
      shell: true,
      env: { ...process.env, NODE_OPTIONS: '' },
    });
    server.unref();
    await setTimeout(2000);
  }

  const mod = await import('aws-blocks');
  api = mod.api;
  authApi = mod.authApi;

  for (let i = 0; i < 30; i++) {
    try { await authApi.getAuthState(); return; } catch {
      await setTimeout(1000);
    }
  }
  throw new Error('Server not ready');
});

test.after(() => {
  if (server?.pid) {
    try { process.kill(-server.pid, 'SIGTERM'); } catch {}
  }
});

テスト実行前の事前処理をtest.beforeで、事後処理をtest.afterで定義します。事前処理としてはバックエンドサーバーが未起動であれば起動し、提供APIを読み込みます。事後処理ではテストスクリプトで起動したバックエンドサーバーを終了します。元からバックエンドサーバーが起動していた場合はそちらを利用します。終了時に停止もしません。

test('unauthenticated calls to the chat API are rejected', async () => {
  await authApi.setAuthState({ action: 'signOut' });
  await assert.rejects(() => api.createConversation());
});

test('authApi getAuthState returns signedOut with signIn/signUp actions', async () => {
  const state = await authApi.getAuthState();
  assert.strictEqual(state.state, 'signedOut');
  const actionNames = state.actions.map((a: any) => a.name);
  assert.ok(actionNames.includes('signIn'));
  assert.ok(actionNames.includes('signUp'));
});

テストケースを実装します。今回は2つのみとしています。

1つめは明示的にサインアウトした後、会話が開始できないことを検証しています。2つめのテストケースでは、サインアウト状態であることを確認し、サインインまたはサインアップ可能であることを検証します。実際のアプリ開発であれば、サインインやチャットが正常に行えるかなども含めるとよいでしょう。

実装したテストコードは次のコマンドで実行可能です。すべてのテストケースでPASSすることを確認しておきましょう。

npm run test:e2e

フロントエンドの実装

フロントエンドであるsrc\index.ts を実装します。UIコンポーネントの定義なども掲載すると長くなるため、ポイントとなる箇所のみ説明します。コード全文はGitHubを参照してください。

import { api, authApi } from 'aws-blocks';

バックエンドが提供するAPIのimportです。これでフロントエンドからバックエンド側のAPIを呼び出すことになります。

import { AccountMenuBar, onAuthChange } from '@aws-blocks/blocks/ui';
...
header.appendChild(AccountMenuBar(authApi));
...
onAuthChange(authApi, () => { void refresh(); });

AWS Blocksが提供している認証関連のUIパーツをimportしています。AccountMenuBar はサインイン/サインアウト、認証時はユーザー名を表示するメニューバーです。今回は画面右上に設置しています。

onAuthChangeは認証状態が変化した際に呼び出されるコールバック関数です。今回はrefreshを呼び出します。refreshでは認証状態によってチャット画面を表示するかどうかを切り替えます。

import { useChat, type ChatMessage } from '@aws-blocks/bb-agent/client';
...
const chat = useChat({
  api: {
    sendMessage: (convId, msg, chId) => api.sendMessage(convId, msg, chId),
    createConversation: () => api.createConversation(),
    getConversation: (id) => api.getConversation(id),
    resume: (chId, responses, convId) => api.resume(chId, responses, convId!),
  },
  subscribe: async (channelId, handler) => {
    const channel = await api.getChannel(channelId);
    return channel.subscribe(handler);
  },
  onMessagesChange: (msgs) => { state.messages = msgs; draw(); },
  onLoadingChange: (loading) => { state.loading = loading; draw(); },
  ...
});

useChatはAWS Blocksが提供するAIエージェント用チャット制御関数です。このインスタンスを生成し、バックエンドのAPIとつなげています。フロントエンドからインスタンスのAPIを呼び出します。

useChatインスタンスでは内部で

  • 会話IDの取得
  • WebSocketの接続
  • ストリーミング対応

など複雑な処理を隠蔽してくれています。そのためフロントエンドからはこれらの複雑な処理を意識することなくチャット機能を簡単に実現することができます。

ローカルでの動作確認

それではローカルPCで動作確認しましょう。ターミナルから以下のコマンドを実行してください。

npm run dev

エラーがなければ、http://localhost:3000でサーバーが起動するため、ブラウザからそちらにアクセスします。画面表示後は右上の「Sign in」ボタンを押し、ダイアログからユーザー作成をすることができます。本記事の検証環境では、ユーザー作成時のVerify Codeは.bb-data\my-app-auth\last-code.json に記載されていました。確認コードの保存場所はバージョンによって異なる可能性があるのでご注意ください。

image 1.png

サインアップ後はチャット画面が表示されます。プロンプトを入力/送信できますが、ローカル実行のため、AIエージェントの回答はモックとなります。

image 2.png

サンドボックスでの起動

次にバックエンドをAWSにデプロイし、テストしてみます。サンドボックスデプロイを開始するには以下のコマンドを実行します。

npm run sandbox

cdk deployが走るため、事前にcdk bootstrapが必要です。デプロイ先のアカウント/リージョンで未実施の方は先に実行しておいてください。
https://docs.aws.amazon.com/ja_jp/cdk/v2/guide/bootstrapping.html

AWS側へのデプロイが完了するとローカルサーバーが起動し、http://localhost:3000/からアプリケーションにアクセスできます。

ローカル実行と異なる点として、認証に利用するAmazon CognitoのユーザープールがAWS上にデプロイされることです。そのため、Verification Codeは、サインアップ時に入力したメールアドレスにAmazon Cognitoから送付されます。またBedrockも利用されるため、モックでなく実際のモデルの応答が返ります。

image 3.png

ポイントとして、ローカルで動作するフロントエンドアプリとAWS上で動作するバックエンドアプリの間をAWS Blocksが提供するDev Serverが中継してくれる点です。Dev ServerをAWS Blocksが提供してくれることで、開発者は意識することなくAWS上のバックエンドに接続できます。

image 4.png

動作確認完了後の後片付けは、以下のコマンドで行えます。

npm run sandbox:destroy

本番環境としてデプロイ

最後に本番環境としてデプロイします。以下のコマンドでデプロイ可能です。

npm run deploy

これにより、バックエンドに加えて、フロントエンドもCloudFrontにデプロイされます。サンドボックスと本番とで、AWSにデプロイされるリソース差分を生成AIにまとめてもらいました。CloudFrontに加え、監視系の機能も追加されているようです。CloudFormationを見る限り他にも多くのリソース作成がありそうなので、あくまで参考としてください。

リソース種別 sandbox deploy 差分
Cognito User Pool ✅ ✅ 同じ
Cognito Client ✅ ✅ 同じ
API Gateway (REST) ✅ ✅ 同じ
API Gateway (WebSocket) ✅ ✅ 同じ
Lambda (RPC ハンドラ) ✅ ✅ 環境変数が違う
Lambda (Agent stream 用) ✅ ✅ 同じ
Lambda (Realtime broadcast) ✅ ✅ 同じ
DynamoDB (会話履歴) ✅ ✅ DeletionProtection が違う
DynamoDB (WebSocket 接続管理) ✅ ✅ 同じ
IAM Role 各種 ✅ ✅ 同じ
CloudWatch Logs ✅ ✅ 同じ
S3 バケット (静的アセット) ❌ ✅ deploy のみ
CloudFront Distribution ❌ ✅ deploy のみ
S3 → CloudFront OAC + Bucket Policy ❌ ✅ deploy のみ
BucketDeployment Lambda + Role ❌ ✅ deploy のみ
SNS Topic (Monitoring) ❌ ✅ deploy のみ
CloudWatch Alarms ❌ ✅ deploy のみ

デプロイしたリソースの後片付けは次のコマンドです。

npm run destroy

まとめ

今回はAWS Blocksを使って、Bedrockと会話するチャットアプリを構築しました。所感として、比較的簡単にバックエンドを構築できたと感じています。ローカル実行やサンドボックスデプロイに必要な設定やユーティリティも提供されているため、開発体験は良いものでした。

一方で、AWS Blocksが対応していない細かい設定を行うには、CDKを直接変更する必要があるため、若干のハードルの高さを感じます。とはいえ、Previewとして公開されたばかりのため、提供されるBlockの拡充や、使いどころを含めたノウハウ共有など、今後のさらなる発展に期待します。

HCEphA9a4AA6hBv.jpg

3
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
3
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?