🚄

TypeScriptの関数だけでRESTサーバーを作れるfrourioを始めよう

2023/09/09に公開

aspidaとTypeScriptフルスタック開発の知識があることを前提として書かれたプロ向けのフレームワーク紹介記事です。
丁寧な文章を書く余裕が筆者にないため、記事で概要を把握しリポジトリのサンプルコードを読んで理解してください。

logo

3年かかってようやくv1.0リリース

Bunのv1.0リリースで盛り上がってますね。frourioも先月ひっそりとv1.0をリリースしました。
広報の頻度が少ないですが、売り上げの8割以上をfrourioで稼いでいるので全力でドッグフーディングしてきました。
来年度も弊社が受注する公共サービスは全てfrourioで開発することになります。

公共機関向けのサービス開発でVercelやCloudflareのイケイケクラウドを使えることはほぼなくて、AWSでEC2+S3+CloudFrontの構成がようやく普及してきたような状況です。
Railsの強いエンジニア達が静的型と動的型どちらが生産的かを議論しているようですが、メンバーの入れ替えが激しいうえに素人の流入が多い公共開発において少なくとも私がリードする場合TypeScriptの型検査は納期と品質を守ることに大きく貢献してくれました。

設計と開発フェーズでは数人分の作業を一人でこなし、運用フェーズでは顧客が契約した派遣エンジニアやSESを教育することでfrourioがベテランと駆け出しエンジニア両方の生産性を向上させています。
LGWANという行政専用ネットワークに対してCDを組むのは困難でたいてい温かみのあるリリース作業をすることになるのですが、最低限CIだけも丁寧にセットアップすればPRレビューや仕様変更のコストを抑えられます。

多数の駆け出しエンジニアチームにおいてあなたがたった一人のまともなTypeScriptエンジニアであり、単純な構成のAWSやオンプレでREST APIのSPAを開発する場合にfrourioは事業利益をもたらしてくれるでしょう。
frourioは「このプロジェクトの炎上を防げるのは自分しかいない」と覚悟を決めたエンジニアの強力な武器です。

ベタなインフラで動くモノリシックなフレームワーク

今時なエッジコンピューティングに対応していませんが、React縛りがなくNode.jsアプリとして一般的な環境にデプロイすることが可能です。

  • Fastifyベースでサーバーが動く
  • クラスやデコレータを使わず全て単純な関数の組み合わせ
  • フロントエンドとバックエンドで共有するaspidaのAPI型定義
  • ファイルベースルーティング
  • Zodでバリデーション可能
  • ORMはPrisma推奨
  • aspidaが使えるフロントエンドフレームワーク全て対応
  • 全ての関数にDI可能
  • LGWAN向け脆弱性診断で92~100点満点を獲得

マーケティング用語としてフルスタックフレームワークを名乗っていますが、aspidaが $api.ts を生成するだけなのと同様に、frourioは $server.ts$relay.ts をFastifyのインターフェースに合わせて生成するだけに過ぎないので実体は非常に小さなフレームワーク、あるいはジェネレーターです。

next-frourio-starterで始めてみる

最近aspidaとfrourioの関連OSSを全面的にアップデートしたのですが、create-frourio-appだけ複雑すぎて修正できませんでした。
プロジェクト開始時に使っていてバックエンド込みで動いているデモまであるNext.jsベースのリポジトリを使って下さい。

https://github.com/solufa/next-frourio-starter

CloneしてREADMEに書いてある通りの手順で以下のデモと同じシステムが動作します。

https://solufa.github.io/next-frourio-starter/

Firebase含めてNode.js以外全てのミドルウェアをDockerに閉じ込めてあるのでアカウント不要でオフラインのままローカルで開発が可能です。

/ /client /server で3回npm installする設計になっているのは個人的にパッケージマネージャーのモノレポ機能に疲弊したというしょうもない理由からです。

API型定義

/server/api/**にREST APIの型定義があります。
aspidaそのままの /server/api/**/index.ts をもとに /server/api/**/$relay.ts が生成されて/server/api/**/controller.tsの実装を型検査できるようになってます。

/server/api/tasks/** がFirebaseによる認証付きTodoアプリのエンドポイントなのでaspidaの知識があればざっと眺めるだけで使い方がわかります。

/server/api/tasks/_taskId@stringでパス変数taskIdをstring型で指定できるのですが、幽霊型に抵抗がなければ/server/api/tasks/index.tsの22行目にある通り、Maybe<TaskId> を使うとよりaspidaと相性が良いのでオススメです。
フロントからはTaskIdしか渡せないけど、バックではMaybeとしてTaskIdを受け取ることで外部入力と検証済みのTaskIdを区別できます。

index.ts
import type { DefineMethods } from 'aspida';
import type { Maybe, TaskId } from 'commonTypesWithClient/ids';
import type { TaskModel } from 'commonTypesWithClient/models';

export type Methods = DefineMethods<{
  patch: {
    reqBody: {
      taskId: Maybe<TaskId>;
      label?: string;
      done?: boolean;
    };
    status: 204;
    resBody: TaskModel;
  };
}>;

フロントでバックエンドの型をimportするには /server/commonTypesWithClientを利用できます。
/client/package.jsonのdependenciesにfile:..で共通化を実現しています。
dependenciesの先にあるcommonTypesWithClientをNext.jsがトランスパイル出来ないので意図せずバックエンドのコードがフロントに含まれてしまうことを防げます。

Modelの型定義をZodで行うことも可能です。

server/commonTypesWithClient/models.ts
import { z } from 'zod';
import { taskIdParser } from '../service/idParsers';

export const taskParser = z.object({
  id: taskIdParser,
  label: z.string(),
  done: z.boolean(),
  created: z.number(),
});

export type TaskModel = z.infer<typeof taskParser>;

/client/next.config.jsでdependenciesの先の /server/api/server/commonConstantsWithClientのみトランスパイルするよう設定されてます。

next.config.js
module.exports = {
  transpilePackages: ['api', 'commonConstantsWithClient'],
};

/server/apiはaspidaの$api.tsをフロントでimportするための設定でこれも型以外のバックエンドコードをフロントに巻き込みません。

$api.ts
import type { AspidaClient, BasicHeaders } from 'aspida';
import { dataToURLString } from 'aspida';
import type { Methods as Methods_by08hd } from '.';
import type { Methods as Methods_18qsrps } from './health';
import type { Methods as Methods_1uc1f5c } from './me';
import type { Methods as Methods_g2ofzy } from './session';
import type { Methods as Methods_w6o6q4 } from './tasks';
import type { Methods as Methods_1vagdt3 } from './tasks/_taskId@string';
import type { Methods as Methods_cw3756 } from './tasks/di';

/server/commonConstantsWithClientはどうしても両方で共有したい定数を注意深く設置することができます。

export const APP_TITLE = 'next-frourio-starter';

認証ガードは /server/api/tasks/hooks.tsを読んでください。
配下のディレクトリ全てに影響しAdditionalRequestをcontrollerで受け取ることが出来ます。

hooks.ts
import type { UserModel } from '$/commonTypesWithClient/models';
import { getUserRecord } from '$/middleware/firebaseAdmin';
import { usersRepo } from '$/repository/usersRepo';
import { defineHooks } from './$relay';

export type AdditionalRequest = {
  user: UserModel;
};

export default defineHooks(() => ({
  preHandler: async (req, res) => {
    const user = await getUserRecord(req.cookies.session);

    if (!user) {
      res.status(401).send();
      return;
    }

    req.user = usersRepo.recordToModel(user);
  },
}));
controller.ts
import { getTasks } from '$/repository/tasksRepository';
import { defineController } from './$relay';

export default defineController(() => ({
  get: async ({ user, query }) => ({
    status: 200,
    body: await getTasks(user.id, query?.limit),
  }),
});

フロントエンドとバックエンドで個別にデプロイ

フロントは /.github/workflows/deploy-client.yml に手順があります。
バックエンドは環境変数をセットして /serverでコマンドを叩くだけです。

$ cd server
$ npm install
$ npm run build
$ npm start

next-frourio-starterデモのデプロイに使っているRailwayの設定画面を参考にしてください。
Firebaseのアカウントが必要です。

Variables

General

Build

Deploy

関数へのDIはvelonaで

/server/api/tasks/di/**に関数にDIする場合のサンプルエンドポイントがあります。

repositoryにvelonaでDI可能な関数を作成し、注入対象のfindManyTask関数とともにexport

server/repository/tasksRepository.ts
import type { Maybe, TaskId, UserId } from '$/commonTypesWithClient/ids';
import type { TaskModel } from 'commonTypesWithClient/models';
import { depend } from 'velona';
import { prismaClient } from '../service/prismaClient';

export const findManyTask = async (userId: UserId) => {
  return await prismaClient.task.findMany({ where: { userId }, orderBy: { createdAt: 'desc' } });
};

export const getTasksWithDI = depend(
  { findManyTask },
  async ({ findManyTask }, userId: UserId): Promise<TaskModel[]> => {
    const prismaTasks = await findManyTask(userId);

    return prismaTasks.map(toModel);
  }
);

コントローラーでfindManyTaskをimportしてgetTasksWithDIにinject

server/api/tasks/di/controller.ts
import { findManyTask, getTasksWithDI } from '$/repository/tasksRepository';
import { defineController } from './$relay';

export default defineController({ findManyTask }, (deps) => ({
  get: async ({ user }) => ({
    status: 200,
    body: await getTasksWithDI.inject(deps)(user.id),
  }),
}));

テスト時にコントローラーへDIが出来ます。
実際はasを使わずUserModelを作成すべきでしょう。

server/tests/api/index.test.ts
test('依存性注入', async () => {
  const res1 = await controller(fastify()).get({
    user: { id: 'dummy-userId' } as UserModel,
  });

  expect(res1.body).toHaveLength(2);

  const mockedFindManyTask = async (userId: UserId): Promise<Task[]> => [
    {
      id: taskIdParser.parse('foo'),
      userId,
      label: 'baz',
      done: false,
      createdAt: new Date(),
    },
  ];

  const res2 = await controller
    .inject({ findManyTask: mockedFindManyTask })(fastify())
    .get({
      user: { id: 'dummy-userId' } as UserModel,
    });

  expect(res2.body).toHaveLength(1);
});

このテストコードはGitHub Actionsで実行されています。
https://github.com/solufa/next-frourio-starter/actions

サポート

TwitterとDiscordどちらでも手が空いた時に反応します。
Discordはクローズドカテゴリを毎日業務で使っているので裏側はアクティブです。

Docs: https://frourio.com
Twitter: https://twitter.com/m_mitsuhide
Discord: https://discord.gg/SARkeDf

Discussion