ESP32-S3 Bench Guide
ESP32-S3 N16R8 bench guide: live pinout, lessons, simulators and the bench log.
翻譯準備中
尚未偵測到更新。GitHub 發布版本後會顯示在這裡。
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證正在載入完整 README。 以下為已儲存的摘錄。
技術書を読んでいて見つけた誤りや「こう書いた方が読みやすい」という気づきを、読者が投稿して共有できる公開サイトです。 投稿には誤りの箇所(ページ・行や版・刷)を添えられ、出版社からの回答とステータスは管理者が記録して公開します。 ℹ️ 掲載されている投稿は投稿者からの報告であり、出版社の回答がつくまでは未確認の情報です。各投稿に表示される出版社の回答・ステータスとあわせてご確認ください。 ※ 数値は 2026-08 時点。 技術書を読んでいると、公式の正誤表にも載っていない誤りに出くわすことがあります。そうした「誤りを見つけ、指摘した」という記録を、技術者自身のポートフォリオとして残せる場が欲しいというのが動機の一つです。 また、投稿しやすいフォームを用意することで、他の技術者の方も気軽に指摘でき、正誤情報がより多くの人に届く——そんな場になればと考えて作りました。 技術的にも、不特定多数が投稿する公開 UGC(ユーザー生成コンテンツ)を題材に、認証・認可・データ整合性・セキュリティといった「実運用で効いてくる設計」を自分の手で一通り実装してみたかった、という狙いもありました。檢視完整 README ↗
技術書を読んでいて見つけた誤りや「こう書いた方が読みやすい」という気づきを、読者が投稿して共有できる公開サイトです。
投稿には誤りの箇所(ページ・行や版・刷)を添えられ、出版社からの回答とステータスは管理者が記録して公開します。
ℹ️ 掲載されている投稿は投稿者からの報告であり、出版社の回答がつくまでは未確認の情報です。各投稿に表示される出版社の回答・ステータスとあわせてご確認ください。
| 開発体制 | 個人開発(要件・設計・実装・運用・法務文書まで) |
| 期間 | 2026-06 〜 継続中(本番公開・検索インデックス済み) |
| 規模 | コミット 597 / マージ済み PR 221 |
| テスト | 単体 376 件(Vitest)/ e2e 212 件(Playwright) |
| 運用 | Vercel 本番稼働 / GitHub Actions で CI / Dependabot / CodeQL |
※ 数値は 2026-08 時点。
技術書を読んでいると、公式の正誤表にも載っていない誤りに出くわすことがあります。そうした「誤りを見つけ、指摘した」という記録を、技術者自身のポートフォリオとして残せる場が欲しいというのが動機の一つです。 また、投稿しやすいフォームを用意することで、他の技術者の方も気軽に指摘でき、正誤情報がより多くの人に届く——そんな場になればと考えて作りました。
技術的にも、不特定多数が投稿する公開 UGC(ユーザー生成コンテンツ)を題材に、認証・認可・データ整合性・セキュリティといった「実運用で効いてくる設計」を自分の手で一通り実装してみたかった、という狙いもありました。
| 領域 | 採用技術 |
|---|---|
| フレームワーク | Next.js 16(App Router)/ React 19 / TypeScript |
| スタイリング | Tailwind CSS v4 / 自作 UI コンポーネント(lucide-react アイコン) |
| 認証 | Supabase Auth(メール確認・PKCE code フロー) |
| データベース | PostgreSQL(Supabase ホスティング) |
| ORM | Prisma v7(@prisma/adapter-pg) |
| バリデーション | Zod |
| テーブル UI | TanStack Table |
| 外部 API | OpenBD(日本語書誌・ISBN)/ Google Books(タイトル検索・書影) |
| テスト | Vitest(単体)/ Testing Library / Playwright(e2e) |
| CI | GitHub Actions(lint + typecheck + test + build)/ CodeQL / Dependabot |
| デプロイ | Vercel |
「不特定多数が投稿する公開 UGC」を題材にしたので、判断の多くはきれいな正解が無く、何かを差し出すものでした。代表的なものを、捨てた側も含めて挙げます。
strict-dynamic にし、代償として全ページ動的レンダリングを受け入れたscript-src に 'unsafe-inline' を置かないので、注入されたインラインスクリプトは実行されません。
捨てたもの: 静的生成と CDN キャッシュ。 静的に生成した HTML にはリクエストごとの nonce を差し込めず、strict-dynamic 下ではスクリプトが全部ブロックされるため、全ルートが動的になります。この変更で静的でなくなったのは 13 ルート(/terms・/how-to-use など文章だけの低トラフィックページ)で、主要ページは DB を読むので元から動的でした。'unsafe-inline' に緩めれば静的に戻せる=後戻りできる形の代償だと確認したうえで XSS 耐性を取っています。
style-src だけは 'unsafe-inline' を許しています(nonce は <style> 要素にしか効かず、React の style 属性には効かないため)。script-src の厳格さを守る方を優先した非対称です。
サーバーレスでは関数インスタンスが使い捨て+並走するのでプロセス内メモリでは数えられず、共有ストアが要ります。Upstash Redis がこの用途の定番ですが採りませんでした。
原子性は INSERT … ON CONFLICT DO UPDATE … RETURNING の1文で取り、ローカル実 DB で並列 20 発 → 許可 5・カウント 20(数え落とし 0)を実測して確認しました。スライディングウィンドウにせず固定ウィンドウにしたのは、目的が「外部コストの青天井を防ぐこと」であって瞬間流量の平滑化ではないからです。
内部 UI からの更新に自前 API Route を挟むのは、同一プロセス内で HTTP 往復と JSON 二重シリアライズを増やすだけで、分離の実も速度も得られません。残した Route Handler は 3 本だけです。
そのぶん穴が開きます。 画像アップロードを Route Handler にしたのは Server Actions のボディ上限が既定 1MB だからですが(bodySizeLimit を緩めると全アクション共通に効いて DDoS 耐性を削る)、Route Handler には Server Actions の自動 CSRF 対策が効きません。同じ検査(Origin と Host の一致)を utils/same-origin.ts に自前で実装し、別オリジン・Origin 欠落を 403 にすることを e2e で担保しています。
管理操作の Server Action は実行時に必ず services/auth.ts の requireAdminServerAction を通し、そこで毎回 DB の最新ロールを確認します。proxy.ts(認証チェックのみ)とレイアウトガードは表示制御と早期リダイレクトのための補助で、セキュリティを保証していません。
Server Action は直接 POST できる公開エンドポイントであり、レイアウトのガードはキャッシュで古くなりうるため、「どこか1つでも通れば守られる」ではなく「ここを通らないと何もできない」場所を決める方が重要でした。
auth.users を削除してログインを不能にし、Profile は残して PII だけスクラブ、Report は保全して「退会済みユーザー」と表示します。公開 UGC では投稿はコミュニティの資産で、消すと他の読者の文脈まで壊れるためです(匿名化すれば GDPR 消去権の対象からも外れます)。
管理者向けの「ユーザー削除」機能は作りませんでした。 スパムや規約違反の掃除も同じ退会処理を代行する形にしています。Report.userId が Restrict で消せないうえ、目的(ログイン不可・PII 消去)はスクラブだけで達成でき、分岐を作らない方が安全だからです。
束ねないと「削除は成立したのに監査ログの書き込みが失敗して『削除に失敗しました』と返る」が起きます。半端な状態と嘘の文言という、利用者からは直しようのない組み合わせが構造的に消えます。
監査ログを書く操作は、すべて束ねています。 一般に「ログ」は観測のための best-effort でトランザクションに入れるものではなく、罠は名前の方でした(createAuditLog は名前がログでも中身は説明義務のある業務記録)。線引きを「取り返しがつくか」にしなかったのは、嘘の文言が、画面で結果が見えて戻せる操作(ステータス更新など)でも同じように起きるためです。
例外は退会の2か所です。Supabase の admin API(外部)をまたぐのでトランザクションに入らず、退会は成立させ、監査ログの失敗はサーバーのログ(console.error)に残すだけにしています。取り消せない退会が済んだ後で「失敗しました」と返すのは嘘になるためです。
外部サービス(Supabase Storage)は束ねられないので、どちらに倒すかを決める問題になります。画像削除は DB を先に消して孤児ファイルを残す側に倒しました(逆に倒すと画像が壊れて表示され、利用者に見える分だけ実害が大きい)。
当初は Report の列に持っていましたが、列のままだと上書き事故が構造的に起きます。ステータス更新のフォームは保存のたびに列の値を丸ごと送るので、管理者がステータスだけ変えるつもりで押すと、その間に出版社が書いた回答が消えます。行を足すだけの形には上書きが存在しません。
スキーマから旧列を消す変更は expand-contract で「移す」と「消す」を別のリリースに分け、間に切り戻せる期間を作りました。
到達しない分岐を潰そうと @typescript-eslint/no-unnecessary-condition を一時的に有効化して件数を測ったところ 14 件中、素直に従ってよいのは 1 件だけでした。残りは外部境界・Server Action の戻り型・キャストに由来する偽陽性で、この構成である限り比率は改善しません。--max-warnings 0 運用なので、常設すると抑制コメントが 13 本並んで本物が埋もれます。
代わりに「棚卸しのときに手で回す検査」に位置づけました。収穫の中身は期待と違っていて、デッドコード検出としては 1/14 でも「キャストが型に嘘をついている箇所」の検出としては 2/2 で当たっており、実際そのうち 1 件はエラーページ自身が 500 になるバグでした。測り方は docs/learnings.md に残してあります。
@unique で重複登録を防ぐ。公開 URL も /books/<ISBN-13> にして、後から変えられない識別子を自然キー側に寄せた設計判断の全量は docs/design.md §7、学習メモは docs/learnings.md にあります。
データモデルの全体像は docs/erd.svg を参照(Prisma スキーマから自動生成)。
.nvmrc に固定。nvm 利用時は nvm use で切り替わる# 1. 依存関係のインストール
npm install
# 2. ローカル Supabase を起動(Docker 必要。Auth+DB+Studio 一式)
supabase start
# 3. ローカル用の環境変数を作成(値は `supabase status` 参照)
cp .env.local.example .env.local
# NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY を supabase status の値に置き換える
# 4. Prisma クライアント生成 & スキーマ反映(.env.local のローカルDBに対して実行)
# (--generator client で ER 図ジェネレータをスキップ)
npx prisma generate --generator client
npx prisma db push
# 5. 管理者ユーザー+サンプルデータを投入(冪等。ログイン: [email protected] / password123)
npx prisma db seed # `npm run seed:local` でも可
# 6. 開発サーバー起動
npm run dev
http://localhost:3000 を開く。ローカル Studio は http://127.0.0.1:54323、受信メール確認は http://127.0.0.1:54324。
| コマンド | 内容 |
|---|---|
npm run dev | 開発サーバー起動 |
npm run build | Prisma クライアント生成 + 本番ビルド |
npm run lint | ESLint |
npm test | 単体テスト(Vitest・1回実行) |
npm run test:watch | 単体テスト(ウォッチモード) |
npm run test:e2e | e2e テスト(Playwright。ローカルの dev サーバーに対して実行) |
型チェックは npx tsc --noEmit で実行できます。これら(lint / typecheck / test / build)は push・PR ごとに GitHub Actions でも自動実行されます。
環境ごとの置き場所:
.env.local(supabase start のローカル Supabase 値。テンプレ = .env.local.example)| 変数名 | 用途 |
|---|---|
DATABASE_URL | アプリ用の DB 接続(本番はコネクションプーラー経由 / ローカルは直結) |
DIRECT_URL | マイグレーション用の直接接続(Prisma v7 は prisma.config.ts で使用) |
NEXT_PUBLIC_SUPABASE_URL | Supabase プロジェクト URL(公開) |
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY | Supabase anon/publishable キー(公開・RLS で保護) |
GOOGLE_BOOKS_API_KEY | Google Books API キー(サーバーサイドのみ・公開しない) |
SUPABASE_SECRET_KEY | Supabase secret キー(サーバー専用・退会処理の auth.users 削除に使用) |
SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID / _SECRET | GitHub ログイン用 OAuth App の資格情報。ローカル= .env.local(supabase start が読む)/ 本番= Supabase ダッシュボードの Provider 設定(Vercel ではない) |
.env*は.gitignore済み(テンプレの*.exampleのみ追跡)。NEXT_PUBLIC_以外の値は秘密情報として扱い、コミットしないこと。本番値はローカルに置かず Vercel で管理する。prisma.config.tsは.env.localを優先して読むため、ローカルの CLI 操作(prisma db push等)が誤って本番DBを叩くことはない。
仕組みの全体像(どの環境がどのファイルを読むか・優先順位・チートシート)は docs/dev-environment.md にまとまっている。
迷ったらまず docs/dev-environment.md — env と環境の地図。冒頭に「⚠️ 忘れてはいけないこと」(本番 DB へのスキーマ反映・config 変更時の再起動・Preview 確認してからマージ 等)を集約している。
| ドキュメント | 内容 |
|---|---|
| docs/dev-environment.md | env・環境・運用の要注意事項。開発で迷ったらここが起点 |
| docs/design.md | 設計方針(将来像と確定済みポリシー) |
| docs/data-model.md | データモデルの解説 |
| docs/erd.svg | ER 図。⚠️ スキーマを変えたら npx prisma generate を手で回す(npm run build はスキップする) |
| docs/learnings.md | 開発中の学習メモ |
| docs/legal/ | 利用規約・プライバシーポリシー(ドラフト) |
| docs/moderation-policy.md | 投稿モデレーション方針(運用内規) |
| docs/project-requirements.md | 初期要件メモ(歴史資料。現状とは差異あり) |
| CLAUDE.md / AGENTS.md | AI エージェント開発時のルール(本番反映の安全ルールは人間にも適用) |
.env.local.example ほか *.example | 環境変数テンプレート(変数名と取得方法の説明つき) |
src/
├── app/ Next.js ルーティング(ページ・API Route)。フィーチャーの合成と、
│ その画面でしか使わない部品・複数のフィーチャーにまたがる操作を置く
├── features/ 関心事ごとのまとまり。そのものだけで完結する操作(Server Action)もここ(下記)
├── components/ フィーチャーに属さない UI(下記)
├── constants/ 横断する定数(routes, site, rate-limits など)。何も import しない
├── generated/ Prisma 自動生成(編集不可・gitignore)
├── lib/ 外部との口そのもの(prisma / supabase のクライアント・OG 画像のフォント取得)
├── services/ DB・認証に触る横断処理(auth, audit, publisher-access, rate-limit, transaction)
└── utils/ 横断する純粋関数(ISBN 正規化・整形・退会の語彙など)
docs/ 設計・学習メモ・ER 図
constants/ → utils/ → lib/ → services/ (shared・ドメインを知らない)
↓
features/ (1つのものの部品・操作・データ。features 同士はつながない)
↓
components/layout/ → app/ (合成層=画面も、複数のものにまたがる操作も、ここで組み立てる)
一方向だけ許す。 図の下から上へは import できない。shared はどこからでも使える。
⭐ shared の中にも同じ順序がある。 constants(値だけ)→ utils(純粋関数)→ lib(外部との口)
→ services(DB・認証に触る)で、下から上へは import できない。おかげで新しい共有ファイルの置き場所が
「外部(DB・認証・fetch)に触るか」の質問1つで決まる — 触らないなら utils/、触るなら services/、
触る口そのものなら lib/。⚠️ この順序は宣言ではなく eslint.config.mjs の
import/no-restricted-paths が落とす(components/ui/ は UI 部品なのでこの列には並ばない)。
⚠️ components/layout/ は shared ではなく合成層に立つ。 ヘッダーやフッターは再利用のための
ライブラリではなく、フィーチャーを組み立てて画面の枠を作るもの。実際にヘッダーの
ユーザーメニューはログアウト(features/account/actions/logout.ts)を持っている。
ここを shared として縛ると、合成のためだけに props を何段もバケツリレーすることになる。
逆流とフィーチャー同士の直接参照は import/no-restricted-paths(eslint.config.mjs)で禁止していて、
違反すると lint が落ちる。規約は文書ではなく lint で守る — ディレクトリを切っただけの規約は必ず崩れるため。
エラー文には向きと理由が出るので、向きを覚えていなくても、間違えたその場で正しい置き場所が分かる。
そのものだけで完結する操作は features/<name>/actions/ に、複数のものにまたがる操作は、
それを使う画面の隣(app/)に置く。
src/
├── features/report/actions/
│ ├── update-own-report.ts 自分の投稿を直す
│ ├── withdraw-own-report.ts 自分の投稿を取り下げる
│ ├── update-report-status.ts 投稿のステータスを変える(管理者)
│ └── …(1操作1ファイル)
├── features/account/actions/ login.ts・logout.ts・withdraw-account.ts …
└── app/(site)/submit/
├── submit-form.tsx 書籍を選ぶ UI と投稿フォームを束ねる
└── create-report.ts 投稿する(書籍と出版社を用意してから投稿を作る=またがる操作)
⭐ 1ファイル=1つの Server Action。 関数名は、ファイル名をキャメルケースにして Action を付ける
(update-own-report.ts → updateOwnReportAction)。
⚠️ Action を付けるのは、データの関数(features/<name>/db/)と名前をぶつけないため。
管理者の単純な操作は、利用者の言葉でも DB の言葉でも同じ動詞(update・delete)になるので、
言葉の選び方では分けられない。
⭐ 複数のものにまたがるかどうかは、import で決まる。 features の中の操作が別のフィーチャーを
import しようとすると lint が落ちるので、そのときは使う画面の隣(app/)へ移す。
画面を組み立てる場所(submit-form.tsx が書籍を選ぶ UI と投稿フォームを束ねる)と、
操作を組み立てる場所が同じになる。今は4本(投稿の作成・書誌の更新・正誤表 URL の採用・出版社アクセスの剥奪)。
⚠️ app の操作は、features の部品から直接は呼べない(features は app を import しない)。 部品は操作を props で受け取る。投稿フォームは作成の操作を受け取る(書籍を選ぶ UI を受け取るのと同じ形)。
認可(誰が呼べるか)・回数制限・入力の検査は、各ファイルの中で行う。管理者だけの操作にも名前に印は付けない (誰が呼べるかは中の認可が示す=下の「管理画面用でディレクトリを切らない」と同じ考え方)。
⭐ トランザクションを開始するのも Server Action。 開始するのは services/transaction.ts の
runInTransaction で、その中で呼ぶ features/<name>/db/ の関数には、トランザクションの中で
DB を操作するためのオブジェクト(tx)を最後の引数で渡す。複数のフィーチャーにまたがる処理
(app/admin/books/[id]/update-book.ts =出版社の用意と書籍の更新)も、1つのトランザクションにまとめられる。
Server Action から prisma を import したり、tx.<モデル> を直接使ったりすると lint が落ちる。
⚠️ 複数の操作が共有する関数は actions の外に置く(features/<name>/ の db・utils か services/)。
"use server" のファイルは async 関数しか export できず、export した関数はブラウザから呼べる口になるため。
共有する型も、どれか1本の中ではなく features/<name>/types.ts に置く。
⚠️ 例外は Route Handler(HTTP で直接受ける必要がある4本=画像のアップロード・書籍検索の中継2本・
ログイン後の戻り先 /auth/callback)。Server Action ではないので、手順はその route.ts の中に書く。
src/features/
├── report/ 投稿・出版社からの回答
├── book/ 書籍マスタ・書籍検索
├── publisher/ 出版社マスタ
└── account/ 登録・ログイン・アカウント設定・退会・ユーザー管理
src/features/report/
├── components/ 画面の部品(actions を呼べる)
├── actions/ このものだけで完結する操作(Server Action・1操作1ファイル)
├── db/ DB に触るもの(対象で名付ける)
├── constants/ ステータス・ラベル・文字数上限
├── schema.ts 入力の検査(zod)
├── types.ts
└── utils/
⭐ actions/ と db/ を分ける軸は「読み書き」ではなく「呼ばれ方」。 ページが描画時に
await するなら db/、ブラウザが操作中に呼ぶなら actions/(Server Action)になる(読み取りでも)。
⭐ DB に触るのは features/<name>/db/ と services/ の中だけ。app/ と actions/ は prisma を import しない
(no-restricted-imports で機械的に禁止)。
共有されているかどうかは基準ではない(1画面しか使わない読みも db/ に置く)。
これは Next.js 自身のガイドに沿っている(node_modules/next/dist/docs/01-app/02-guides/data-security.md)。
公式は2つの方式を挙げたうえで **Data Access Layer を「新規プロジェクト向け」、page.tsx への直書きを
「プロトタイプと学習向け」**とし、さらに "We recommend choosing one data fetching approach and
avoiding mixing them."(混在を避けよ)と書いている。混ぜると「この画面はどちらだったか」を
毎回確かめることになり、監査する側も追えない。
db/ の中が満たすこと(公式が挙げる DAL の3条件):
import "server-only"。クライアントから import するとビルドが落ちるservices/publisher-access.ts)
⚠️ 書き込みの認可も呼び出し側(Server Action・Route Handler)が行う。公式の例は DAL の中で行うが、ここでは外に出す
(同じ関数を、認可の違う操作が呼ぶため。例: 出版社を名前で用意する関数は、投稿する人の操作と管理者の操作の両方が呼ぶ)Report(types.ts)がその形で、
退会判定に使う email は db/ の中で捨てられる=ページは email を持つ値に触れない⚠️ db/ のファイルに "use server" を付けない。 付けると Server Action 扱いになり、
クライアントから呼べるエンドポイントとして公開されてしまう。
⚠️ 共有層では services/(横断層)だけが prisma を直接叩く。 共有層はフィーチャーを import できないため
(import/no-restricted-paths)。依存の向きを崩さないための例外で、監査ログの読み書き
(services/audit.ts)のようにどのフィーチャーのものでもないものがここに来る。
features の中で prisma を触ってよいのは db/ だけ(features の外で触ってよいのは services/・lib/prisma.ts。
features と app/ は no-restricted-imports で機械的に禁止)。actions/ は db/ の関数を呼ぶだけで、components/ utils/ constants/ schema.ts types.ts は DB を知らない。
features/report/
├── db/ ← DB に触るのはここ
│ ├── reports.ts 投稿(公開側)
│ ├── reports-admin.ts 投稿(管理側)
│ ├── report-images.ts 添付画像
│ └── report-deletion.ts 投稿を消す操作が共有する処理
└── components/ constants/ utils/ schema.ts types.ts
⭐ db/ の中は「対象」で名付ける。 1つの仕事が複数の対象にまたがるなら、その仕事の名前で
(account/db/withdrawal.ts は Profile と auth の両方を触るので「退会」)。
⭐ この命名の基準は「定義を読まなくても通じるか」。 db/ は定義が要らない(データベースに触る、以上)。
service や queries のような名前は使う前に定義を書く必要があり、定義を読まない人に誤読される。
⚠️ 読みか書きかはファイル名ではなく関数名が示す(find* は読み)。
ファイル名に重ねると、書き込みが1本入った時点でファイル名と中身が食い違う。
⚠️ 公開と管理は別ファイルにする(reports.ts と reports-admin.ts)。DTO を混ぜないための分割で、
片方に足した欄がもう片方から漏れるのを防ぐ。それ以外は大きくなるまで1ファイルでよい。
⚠️ 分ける軸は「誰が呼ぶか」であって読み/書きではない。 actions/ にあるのは "use server" が
要るもの=ブラウザが呼ぶもので、読み取りでもここに入る。
⚠️ actions/ に置いた関数はすべて外から POST できる口になる("use server" はファイル単位)。
だからフォーム以外から呼ぶもの(Route Handler 用)は actions/ に置けず、db/ に入る。
⚠️ 管理画面用の DTO は公開側と分ける(findReportById と findReportForAdmin)。
管理者に出す欄と読者に出す欄は違い、片方に足した欄がもう片方から漏れるのを防ぐため。
⚠️ service.ts という名前は使わない。 service は流派ごとに指すものが違い(Spring なら業務ロジック、
DDD ならドメインのふるまい)、どの読みでも「読み取りの置き場」にはならない。読み手に予想を作らせて
外す名前は、名前が無いより高くつく。ファイル名は層ではなく扱う対象で付ける
(reports.ts / withdrawal.ts / audit.ts / publisher-access.ts)。
フィーチャー同士は直接つながない。組み合わせるのは app(画面も、複数のフィーチャーにまたがる操作も)。
例が投稿フォームで、app/(site)/submit/submit-form.tsx が
書籍を選ぶ UI(features/book)と投稿フォーム(features/report)を束ね、
選ばれた本を props で渡している。フォーム側は「本を選ぶ UI」をスロットとして受け取るだけで、
それが何なのかを知らない。投稿を作る操作(同じ場所の create-report.ts)も、同じく props で受け取る。
フィーチャーの切り口はテーブルの数ではなく「独立して存在できるか」。
出版社からの回答は投稿にしか付かず投稿なしには存在できないので、独立したフィーチャーにせず
features/report/ に含めている(Publisher=出版社マスタは別)。
⚠️ バレルファイル(index.ts での再 export)は作らない。 直接 import する。
src/components/
├── ui/ ドメインもルーティングも知らない部品(button, cta-link, icons, nav-link,
│ notice, number-field, select-field, theme-toggle)
└── layout/ 全ページの外側を作るもの(site-shell, site-header, header-nav, footer,
breadcrumbs, legal, legal-shell, error-content, not-found-content)
新しいファイルの置き場所は、上から順に当てはめて決める。
app/ の使う場所の隣。
使う画面が1つならそのディレクトリに、複数なら共通の親に置く
(管理画面の5つの一覧が使うページ送りは app/admin/pagination.tsx)features/<name>/actions/、複数のフィーチャーにまたがるなら使う画面の隣(app/)features/<name>/app/ に置く。フィーチャー同士を直接つながない
(複数のフィーチャーにまたがる操作も、2 のとおり app/)components/layout/components/ui/utils//値だけ → constants//触る処理 → services//
外部との口そのもの(クライアント生成) → lib/「共通かどうか」では分けない。共通性は使われている箇所の数であって、置き場所で表せる性質ではないため
(features/report/components/report-fields.tsx は投稿・編集・追記・取り下げ・出版社からの回答が
共有している部品だが、投稿を知っているので components/ui/ には入らない)。
⚠️ 1と3の境目は、使われている箇所の数だけでは決まらない。 使うページが1つしか無くても、
フィーチャーの中の他のファイルと繋がっている部品は 3(features/)に残す。
report-form report-edit-form report-addenda report-withdraw はどれも呼び出し元が1ページだが、
report-fields を共有している。report-card も1ページからしか呼ばれないが report-table から使われる。
数は目安で、判定は**「そのページの中身そのものか、フィーチャーの部品か」**。
管理画面から app/ へ移した7つは、features 内の誰とも繋がらず、呼び出し元も1ページだけだった。
⚠️ 「管理画面用」でディレクトリを切らない。 ディレクトリツリーが表せる軸は1本だけで、
ここでは関心事(投稿・書籍・出版社・アカウント)に使っている。そこへ利用者(公開/管理)という
2本目の軸を混ぜると、同じ部品の置き場所が2通りになって規約が決まらなくなる。
管理画面専用であることは、フォルダ名ではなく置かれている場所(app/admin/ の配下にある)と
認可(app/admin/layout.tsx の requireAdminPage())が既に示している。
実測でも、管理画面の部品8つのうち7つは呼び出し元が1ページしか無く、まとめる意味が無かった。
テストは実装の隣に置く(コロケーション・*.test.ts(x))。何をどこで担保するかは分けている。
| 対象 | どこで |
|---|---|
| 純粋関数・Server Action・service | unit(Vitest)で全部書く |
| コンポーネント | ロジック(分岐・状態)を持つものだけ unit を書く |
| 見た目・操作・アクセシビリティ | e2e(e2e/・Playwright)。すべての PR で CI が回し、通らないとマージできない |
カバレッジ率は計測していない。上の分担ではコンポーネントの数値が意図的に低くなるため、 率そのものが誤読を招く数字にしかならない。
A. 問題ありません。コードが読まれることを前提に設計しているからです。
拠り所はケルクホフスの原則です。19 世紀の暗号学者ケルクホフスが示したもので、「暗号方式は、その仕組みが敵に知られても、鍵さえ秘密であれば安全でなければならない」という考え方です。裏を返せば、仕組みを隠すことで成り立っている安全は、隠しきれなくなった時点で消えるということでもあります。これをソフトウェアに当てはめると「秘密にするのは鍵だけで、コードは公開されても成立させる」になります。
このプロジェクトで実際にしていることは 3 つです。
NEXT_PUBLIC_ を付けていないので、ブラウザへ配られるバンドルにも入りません。/api/books/search はログイン必須です。鍵が漏れなくても、叩かれ続ければ API の無料枠は溶けるからです。逆に、この原則を採ると使えなくなる手もあります。 「管理画面の URL を推測されにくくしておく」のような、知られていないことに頼った守りは数に入れられません。そのぶん、認可・RLS・CSRF 検査といった実際に効く保護を、抜けなく置く必要があります。
考え方の詳細は docs/learnings.md に整理しています。
本プロジェクトにはオープンソースライセンスを付与していません。 そのため、著作権法上の権利はすべて作者に帰属します(All rights reserved)。 コードはポートフォリオとしての公開であり、無断での複製・再配布・再利用はご遠慮ください。
| e2e/ + playwright.config.ts |
| e2e テスト。ローカルはシード垢自動・外部 URL 実行の注意はコメント参照 |