iTranslated by AI

The content below is an AI-generated translation. This is an experimental feature, and may contain errors. View original article
🚄

Getting Started with frourio: Building REST Servers Using Only TypeScript Functions

に公開

This is a framework introduction article for professionals, written with the assumption of knowledge in aspida and TypeScript full-stack development.
The author does not have the leisure to write detailed explanations, so please grasp the overview from the article and understand it by reading the sample code in the repository.

logo

Finally Released v1.0 After 3 Years

There's a lot of excitement around the v1.0 release of Bun. frourio also quietly released v1.0 last month.
Publicity is infrequent, but since over 80% of our revenue is earned through frourio, we have been dogfooding it to the fullest.
In the next fiscal year, all public services commissioned to our company will be developed using frourio.

In service development for public institutions, it's rarely possible to use trendy clouds like Vercel or Cloudflare; a setup with EC2+S3+CloudFront on AWS is just finally becoming common.
While strong Rails engineers seem to be debating whether static or dynamic typing is more productive, in public sector development where member turnover is high and there's a large influx of beginners, TypeScript's type checking has greatly contributed to meeting deadlines and maintaining quality—at least when I am leading.

In the design and development phases, I handle work for several people alone, and in the operation phase, frourio improves the productivity of both veterans and junior engineers by educating dispatched engineers and SES staff contracted by the client.
Setting up CD for LGWAN (a dedicated network for Japanese local governments) is difficult, and usually involves manual ("warm") release tasks, but if you at least set up CI carefully, you can suppress the costs of PR reviews and specification changes.

If you are the only competent TypeScript engineer in a team of many beginners and are developing a REST API SPA on a simple AWS configuration or on-premise, frourio will bring business value.
frourio is a powerful weapon for engineers who have resolved that "I am the only one who can prevent this project from going up in flames."

A Monolithic Framework That Runs on Standard Infrastructure

It does not support modern edge computing, but it can be deployed to general environments as a Node.js application without being tied to React.

  • Server runs on Fastify
  • Everything is a combination of simple functions, without using classes or decorators
  • aspida API type definitions shared between frontend and backend
  • File-based routing
  • Validation possible with Zod
  • Prisma is the recommended ORM
  • Supports all frontend frameworks that can use aspida
  • Dependency Injection (DI) possible for all functions
  • Achieved 92-100/100 points in vulnerability assessments for LGWAN

While it calls itself a full-stack framework for marketing purposes, in reality, it is a very small framework or generator. Just as aspida only generates $api.ts, frourio simply generates $server.ts and $relay.ts to match the Fastify interface.

Getting Started with next-frourio-starter

Recently, I updated all OSS related to aspida and frourio, but create-frourio-app was too complex to fix. Please use the Next.js-based repository, which was used at the start of the project and includes a demo that runs with the backend.

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

Clone it and follow the steps in the README to run the same system as the demo below.

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

All middleware except Node.js, including Firebase, is contained within Docker, allowing for local development offline without needing an account.

The design, which requires npm install three times in /, /client, and /server, is for the rather trivial reason that I personally got exhausted by the monorepo features of package managers.

API Type Definitions

There are REST API type definitions in /server/api/**.
Based on the standard aspida /server/api/**/index.ts, /server/api/**/$relay.ts is generated, allowing the implementation of /server/api/**/controller.ts to be type-checked.

/server/api/tasks/** are endpoints for a Todo app with Firebase authentication. If you have knowledge of aspida, you can understand how to use it by just glancing through them.

In /server/api/tasks/_taskId@string, you can specify the path variable taskId as a string type. If you're comfortable with phantom types, using Maybe<TaskId> as seen in line 22 of /server/api/tasks/index.ts is recommended as it works better with aspida.
While the frontend can only pass TaskId, receiving it as Maybe on the backend allows you to distinguish between raw external input and a verified 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;
  };
}>;

To import backend types in the frontend, you can use /server/commonTypesWithClient.
Sharing is achieved by using file:.. in the dependencies of /client/package.json.
Since Next.js cannot transpile commonTypesWithClient located within dependencies, this prevents backend code from being unintentionally included in the frontend.

It is also possible to define Model types using 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>;

In /client/next.config.js, only /server/api and /server/commonConstantsWithClient within dependencies are configured to be transpiled.

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

/server/api is the setting for importing aspida's $api.ts on the frontend, and this also does not pull in backend code other than types.

$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 can be used to carefully place constants that you absolutely want to share between both sides.

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

For authentication guards, please read /server/api/tasks/hooks.ts.
This affects all subdirectories, and you can receive AdditionalRequest in the 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),
  }),
}));

Deploying Frontend and Backend Individually

The procedure for the frontend can be found in /.github/workflows/deploy-client.yml.
For the backend, simply set the environment variables and run the commands in /server.

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

Please refer to the Railway configuration screens used for the next-frourio-starter demo deployment.
A Firebase account is required.

Variables

General

Build

Deploy

DI for Functions with velona

There is a sample endpoint for DI into functions in /server/api/tasks/di/**.

Create a function in the repository that can be DI-ed with velona, and export it along with the findManyTask function to be injected.

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);
  }
);

Import findManyTask in the controller and inject it into getTasksWithDI.

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 into the controller is possible during testing.
In practice, you should probably create a UserModel without using as.

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);
});

This test code is executed on GitHub Actions.
https://github.com/solufa/next-frourio-starter/actions

Support

I respond on both Twitter and Discord whenever I have time.
I use closed categories on Discord daily for work, so I am active behind the scenes.

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

Discussion