TypeScript モノレポで作るファイル判定システム - WEB/API/Batch 統合開発
はじめに
2025年12月09日分のアドベントカレンダーの記事になります。
こんにちは、マーズフラッグの川嶋です。
本年アドベントカレンダー2本目の投稿になります。
はりきっていきましょう!
最近、弊社では Git リポジトリが増え続け、管理が少々苦痛になってきました。Python リポジトリの一部はモノレポで構築しているものの、TypeScript リポジトリのモノレポはまだ導入していなかったため、今回チャレンジしてみることにしました。
本記事では、Turborepoを使ったモノレポ構成で、WEB(フロントエンド)、API(バックエンド)、Batch(バックエンド)の 3 層を統合管理し、Google が公開したMagika(AI を活用したファイル形式検出ライブラリ)を使ったファイル判定システムを構築した作ってみたいと思います。
余談ですが、本来この Magika は非常に高速に動作し、API に直に処理を記載してもそれなりに動作します。今回は 3 層構成を明確にする目的で、Batch 処理としてファイル形式検出を実装しています。
なぜモノレポを選んだのか
課題
- リポジトリ数の増加による管理コストの上昇
- フロントエンドとバックエンドで型定義が重複
- 共通ロジックの同期が困難
モノレポのメリット
弊社では OpenAPI 定義から API クライアントを生成していますが、生成された型を直接利用すると、API クライアントのバージョン変更や型属性の変更時に影響範囲が大きくなります。そこで、packages/sharedに詰め替え用の型を定義することで、OpenAPI 型の恩恵を受けつつ変更の影響を局所化でき、3 層すべてで一貫した型を利用できるためコードの見通しが向上します。
- 型の共有: フロントエンド・バックエンド・バッチで同じ型定義を利用
-
コードの再利用: 共通ロジックを
packages配下で一元管理 - 一括管理: 依存関係のバージョン管理が容易
- 開発効率: 1 つのコマンドで全体をビルド・テスト可能
プロジェクト構成
mono-repo-sample/
├── apps/
│ ├── web/ # Nuxt 4 (SPA/SSR/SSG)
│ ├── api/ # Fastify 5 + OpenAPI
│ └── batch/ # バッチワーカー (Magika)
├── packages/
│ ├── types/ # 共有型定義
│ ├── shared/ # 共有ドメインロジック
│ └── typescript-config/
└── turbo.json
技術スタック
| レイヤー | 技術 | バージョン |
|---|---|---|
| フロントエンド | Nuxt | 4.2.1 |
| バックエンド | Fastify | 5.6.2 |
| バッチ | tsx + Magika | 1.0.0 |
| モノレポ | Turborepo | 1.13.0 |
| パッケージ管理 | pnpm | 8.15.0 |
| テスト | Vitest | 4.0.15 |
| ジョブキュー | Dragonfly | 1.23.1 |
Dragonfly について
Dragonflyは、Redis 互換の高性能インメモリデータストアです。本プロジェクトでは、API と Batch Worker 間のジョブキューとして使用しています。
- Redis 互換: 既存の Redis クライアントをそのまま利用可能
- 高性能: Redis と比較して最大 25 倍のスループットを実現
- 省メモリ: より効率的なメモリ使用
- シンプル: 単一バイナリで簡単にセットアップ可能
本プロジェクトでは、Docker Compose でポート 6380 に Dragonfly を起動し、他のプロジェクトの Redis(ポート 6379)と独立して動作させています。
実装のポイント
1. Nuxt 4 で SPA/SSR/SSG の 3 パターンを実装
せっかく Nuxt を採用しているので、WEB フロントエンドでは同じファイル判定機能をSPA、SSR、SSGの 3 つのレンダリング方式で実装し、各ページの生成方法の違いも表現しました。
SPA 版(クライアントサイドレンダリング)
// apps/web/pages/dashboard/app-spa.vue
const handleSubmit = async () => {
if (!selectedFile.value) return;
loading.value = true;
const formData = new FormData();
formData.append("file", selectedFile.value);
// 非同期ジョブを投入
const createResponse = await $fetch<JobCreateResponse>("/api/jobs", {
method: "POST",
body: formData,
});
// ポーリングでステータス確認
await pollJobStatus(createResponse.jobId);
};
SSR 版(サーバーサイドレンダリング)
// apps/web/pages/dashboard/app-ssr.vue
// サーバー側でAPIを呼び出し、初期データを取得
const { data: initialData } = await useAsyncData("fileType", async () => {
// SSR時のデータ取得ロジック
});
SSG 版(静的サイト生成)
// apps/web/pages/dashboard/app-ssg.vue
// ビルド時に静的HTMLを生成し、クライアント側でAPIを呼び出す
2. Magika を使った AI ファイル判定
Google の Magika ライブラリを使い、ファイルの内容からファイル形式を判定します。
// apps/batch/src/worker.ts
import { Magika } from "magika";
import { jobRepository, JobResult } from "@repo/shared";
const magikaInstance = await Magika.create();
// ファイル判定
const identifyResult = await magikaInstance.identifyBytes(
job.parameter.fileData
);
const output = identifyResult.prediction?.output;
const jobResult = new JobResult(
job.parameter.fileName,
output.label || "unknown",
output.is_text || false,
score,
scorePercent,
output.description || "",
output.group || "unknown",
output.mime_type || "application/octet-stream",
Array.isArray(output.extensions)
? output.extensions.join(", ")
: output.extension || ""
);
await jobRepository.completeJob(job.jobId, jobResult);
3. 共有パッケージによる型安全性の担保
モノレポの最大のメリットは、共有パッケージによる型や関数の再利用です。今回は API と Batch で利用する型を定義していますが、全層にわたって利用する型があれば packages/shared で定義します。DDD などでビジネスロジックをドメイン層に集中管理し各層で利用する場合などは、さらに効果が発揮されるのではないでしょうか。
packages/shared の構成
// packages/shared/src/index.ts
export * from "./job";
export * from "./repository";
// packages/shared/src/job/JobParameter.ts
export class JobParameter {
constructor(
public readonly fileData: Uint8Array,
public readonly fileName: string
) {}
}
// packages/shared/src/job/JobResult.ts
export class JobResult {
constructor(
public readonly fileName: string,
public readonly fileType: string,
public readonly isText: boolean,
public readonly score: number,
public readonly scorePercent: string,
public readonly description: string,
public readonly group: string,
public readonly mimeType: string,
public readonly extension: string
) {}
}
API 側での利用
// apps/api/src/api/fastify/jobs.ts
import { jobRepository, JobParameter } from "@repo/shared";
const parameter = new JobParameter(uint8Array, data.filename);
const jobId = await jobRepository.createJob(parameter);
Batch 側での利用
// apps/batch/src/worker.ts
import { jobRepository, JobResult } from "@repo/shared";
const jobResult = new JobResult(/* ... */);
await jobRepository.completeJob(job.jobId, jobResult);
このように、@repo/sharedパッケージを介して、API・Batch・WEB の 3 層で同じ型定義とドメインロジックを共有できます。
4. 非同期ジョブパターンの実装
ファイル判定処理は時間がかかるため、非同期ジョブパターンを採用しました。
開発コマンド
# 全体を同時起動
pnpm dev
# 個別起動
pnpm --filter web dev # http://localhost:3001
pnpm --filter api dev # http://localhost:3002
pnpm --filter batch dev # バッチワーカー
# テスト実行(全テスト)
turbo run test
まとめ
今回は小さめのシステムで試してみましたが、実際にコードを書いてみると、型や処理を 3 層で共通化できるメリットを肌で実感できました。pnpm のおかげで各層の起動も速く、開発体験としてもかなり快適でしたね。
Magika を使ったファイル判定システムを例に実装しましたが、この構成は他のシステムにも応用できそうです。TypeScript でモノレポを検討している方の参考になれば幸いです。
参考リンク
- GitHub: mono-repo-sample







