iTranslated by AI

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

frourio: A Functional TypeScript Full-Stack Framework with File-Based Routing

に公開

This is an article where the developer himself introduces frourio, which has been a bit of a hot topic lately as a framework for breaking through the "15 million yen annual income wall" that every freelancer working with TypeScript eventually hits.

https://twitter.com/m_mitsuhide/status/1602160909639876609

With the evolution of Next.js and the emergence of tRPC, I feel that the era of full-stack TypeScript is upon us. Major options for writing servers in TypeScript would likely be:

  • Express
  • NestJS
  • Fastify

The frourio I am about to introduce was designed three years ago when I was exhausted by "decorator-dependent NestJS, which focused so much on DI that it sacrificed other developer experiences," and instead built it as a functional framework that constrains Fastify with aspida type definitions.
(I have also released frourio-express, just in case.)

Features of frourio

  • Define REST APIs based on the file system, similar to Next.js API Routes
  • Develop both frontend and backend driven by type definitions
  • Everything, including controllers, dependency injection, validation (Zod), and ORM (Prisma), is written using functions
  • The core is Fastify with low overhead, so the execution speed is sufficiently fast
  • It is so aspida-friendly that its name during early development was actually aspida-server

Above all, the logo is incredibly cute.

logo

While RPC and GraphQL are popular in the trendy startup scene, REST APIs are still the mainstream when looking at the entire web development market, which is worth about 2 trillion yen annually.
I have been using frourio for almost all of my work and private projects since around spring of this year, and it is worth trying at least once for REST API projects.

I call it a full-stack framework for convenience, but in reality, it is simply a CLI tool that automatically generates function definition files to connect best-practice libraries from the frontend to the backend in a type-safe manner.

architecture

Basic configuration of frourio

  • Frontend: Next.js or Nuxt2 (I tried Nuxt3, but it seems difficult)
  • API requests: aspida
  • Server: Fastify or Express
  • Validation: Zod
  • ORM: Prisma or TypeORM
  • Dependency injection: velona (a functional DI library)

frourio is what generates type-safe defineController functions using the aspida type definitions under /server/api.

Type-safe defineController function

For example, if you write the aspida definition as follows:

/server/api/articles/index.ts
type Article = {
  userId: string
  articleId: string
  content: string
}

export type Method = {
  get: {
    resBody: Article[]
  }
  post: {
    reqBody: {
      content: string
    }
    resBody: Article
  }
}

A relay file named $relay.ts is then automatically generated, and the Controller can be written like this:

/server/api/articles/controller.ts
import z from 'zod'
import { defineController } from './$relay'
import { articleModel } from '$/domain/article/model'
import { articleRepository } from '$/domain/article/repository'

export default defineController(() => ({
  // Function only if validation is not required
  get: async ({ user }) => {    
    const articles = await articleRepository.findAllById(user.id)
    return { status: 200, body: articles }
  },
  // Object if performing validation
  post: {
    validators: {
      body: z.object({ content: z.string().min(1).max(140) })
    },
    handler: async ({ user, body }) => {
      const article = await articleModel.create(user.id, body.content)
      await articleRepository.save(article)

      return ({ status: 204, body: article })
    }
  }
}))

Since defineController imported from the relay file is aware of the aspida type definitions in the same directory, type checking is possible for everything shown above.
user is assumed to have authentication logic and type definitions defined in hooks.ts in a parent directory.

/server/api/hooks.ts
import { defineHooks } from './$relay'

export type AdditionalRequest = {
  user: {
    id: string
  }
}

export default defineHooks(() => ({
  onRequest: (req, _, done) => {
    req.user = { id: 'taro' }
    done()
  }
}))

Environment setup with a single command

frourio also has an environment setup tool similar to those found in major frameworks.

$ yarn create frourio-app # or npm

After running this, you can start by choosing your preferred library configuration in the GUI that is automatically displayed.

create-frourio-app

If you just want to give it a try, selecting SQLite for the database item is easiest.
For PostgreSQL and MySQL, you need to prepare the database in advance.

Once you start, a Next.js v13 Todo app will launch.

Next.js

Since it comes with CRUD operations, authentication, and file uploading working in a simple state, you might be able to figure out how to use it intuitively if you are familiar with aspida, even without reading the documentation.

If you want to build frourio on your own without relying on the tool, this article might be helpful.

https://zenn.dev/luma/articles/ae4f025505e0c8

Define REST APIs based on the file system

Whenever you create an API endpoint directory under /server/api while the development server is running, templates for the type definitions, Controller, and path variable validation that the user needs to write are automatically generated, separate from the relay files managed by frourio.
While this feature is not directly related to service quality, I like it very much because it makes it easy to add APIs.

/server/api/articles/_articleId/index.ts
export type Methods = {
  get: {
    resBody: string
  }
}
/server/api/articles/_articleId/controller.ts
import { defineController } from './$relay'

export default defineController(() => ({
  get: () => ({ status: 200, body: 'Hello' })
}))

When there are path variables, a validation template reflecting the variable names is generated in a separate file as follows. You can edit it yourself if you want to add more detailed constraints.

/server/api/articles/_articleId/validators.ts
import { z } from 'zod'
import { defineValidators } from './$relay'

export default defineValidators(() => ({
  params: z.object({ articleId: z.string() })
}))

API requests are left to aspida

As you add files under /server/api, the aspida type definitions are also updated, so you can use them in the frontend as usual.

const articles = await apiClient.articles._articleId('hoge').$get()

Refer to the previous article for more details about aspida.

https://zenn.dev/solufa/articles/getting-started-with-aspida

Dependency injection is possible for any function

Apart from frourio, I have released a functional DI library called velona.
https://github.com/frouriojs/velona

It is a tiny library of only 15 lines internally, but it allows you to inject objects and functions into functions.

import { depend } from 'velona'

const add = (a: number, b: number) => a + b

export const basicFn = depend(
  { add },
  ({ add }, a: number, b: number, c: number) => add(a, b) * c
)

console.log(basicFn(2, 3, 4)) // 20
import { basicFn } from './'

const injectedFn = basicFn.inject({ add: (a, b) => a * b })

expect(injectedFn(2, 3, 4)).toBe(2 * 3 * 4) // pass
expect(basicFn(2, 3, 4)).toBe((2 + 3) * 4) // pass

While it doesn't strictly adhere to the "Depend upon abstractions" principle, DI is most effective during testing, and the goal of "switching dependent modules during testing" can be achieved with velona.

Velona provides initial values for dependencies to functions; application code uses these defaults, while they can be replaced by injection during testing.
frourio integrates this feature into defineController, so it can be used immediately without additional imports.

/server/api/articles/controller.ts
import z from 'zod'
import { defineController } from './$relay'
import { articleModel } from '$/domain/article/model'
import { articleRepository } from '$/domain/article/repository'

export default defineController(
  { articleModel, articleRepository }, // Initial values for dependencies
  ({ articleModel, articleRepository }) => ({ // Injected dependency modules
  get: async ({ user }) => {    
    const articles = await articleRepository.findAllById(user.id)
    return { status: 200, body: articles }
  },
  post: {
    validators: {
      body: z.object({ content: z.string().min(1).max(140) })
    },
    handler: async ({ user, body }) => {
      const article = await articleModel.create(user.id, body.content)
      await articleRepository.save(article)

      return ({ status: 204, body: article })
  }
}))

Easy to Introduce Fastify (or Express) Plugins

frourio itself is not concerned with CORS or authentication, so it relies on Fastify itself.
The initialization process for Fastify is written in /server/service/app.ts, so you can introduce any plugins you like.

import server from '$/$server'
import { API_BASE_PATH, CORS_ORIGIN } from '$/service/envValues'
import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import type { FastifyRequest, FastifyServerFactory } from 'fastify'
import Fastify from 'fastify'

export const init = (serverFactory?: FastifyServerFactory) => {
  const app = Fastify({ serverFactory })

  app.register(helmet)
  app.register(cors, CORS_ORIGIN ? { origin: CORS_ORIGIN } : undefined)
  server(app, { basePath: API_BASE_PATH })

  return app
}

frourio v0.30 and earlier depend on Fastify v3, while frourio v0.31 and later depend on Fastify v4, so please be careful with plugin versions.

Presumably, for official plugins, names like fastify-xxx are for Fastify v3, while @fastify/xxx are for Fastify v4.

Future Outlook

From here on, please read the official documentation, where @su8ru adds new features every month.
There is a helpful tutorial, and the English version recently gained dark theme support.
https://ja.frourio.com/

I mentioned at the beginning of the article that it's "the best choice for REST API projects," but it's a shame that type definitions for error responses are not yet possible.
This requires the completion of aspida v2. Progress is more than a year and a half behind schedule, but I want to make it happen next year...

Giving frourio a star would be a great encouragement for OSS development.
https://github.com/frouriojs/frourio

Discussion