Skip to content
在 AI 工具中打开 Anthropic
← Blog

Elysia 与 Supabase:您下一个以超音速构建的后端

Elysia 和 Supabase 像 CPU 一样紧密相连

Supabase 是一个开源的 Firebase 替代品,已成为开发者们快速开发的热门工具包。

它提供了 PostgreSQL、即用型用户认证、无服务器边缘功能、云存储等功能,供您随时使用。

因为 Supabase 已经预构建并组合了情境,您可以减少重复开发相同功能的代码行数,将其缩短到不到 10 行代码。

例如,对于认证,这通常需要您为每个项目重写一百行代码,仅需:

ts
supabase.auth.signUp(body)

supabase.auth.signInWithPassword(body)

然后 Supabase 将处理剩余的部分,通过发送确认链接来验证电子邮件,或者使用一个魔术链接或一次性密码 (OTP) 进行认证,确保您的数据库拥有行级认证,您说了算。

在每个项目中需要耗费数小时重新做的事情,现在只需一分钟即可完成。

Elysia ​

如果您还没有听说,Elysia 是一个以 Bun 为核心的 web 框架,旨在提升速度和开发者体验。

Elysia 的性能比 Express 快近 20 倍,同时其语法几乎与 Express 和 Fastify 相同。

(性能可能因机器而异,我们建议您在决定性能之前在您的机器上运行 基准测试) ​

Elysia 提供了极为灵活的开发者体验。 不仅可以定义单一事实来源类型,并且在您意外修改数据时还可以检测并报警。

这一切都通过简洁的声明式代码实现。

设置 ​

您可以使用 Supabase Cloud 快速入门。

Supabase Cloud 将处理数据库的设置、扩展和您在云中所需的所有内容,只需单击一下即可完成。

Supabase 登陆页面

创建项目时,您应该会看到类似以下界面,填写所有所需的请求,如果您在亚洲,Supabase 在新加坡和东京都有服务器。

(有时这对生活在亚洲的开发者来说是一个决定性因素,因为延迟问题) ​
创建新的 Supabase 项目

创建项目后,您应该会看到一个欢迎屏幕,可以在其中复制项目 URL 和服务角色。

这两者用于指示您在项目中使用的是哪个 Supabase 项目。

如果您错过了欢迎页面,请导航到 设置 > API,复制 项目 URL 和 项目 API 密钥。

Supabase 配置页面

现在在您的命令行中,通过运行以下命令开始创建 Elysia 项目:

bash
bun create elysia elysia-supabase

最后一个参数是我们要创建的 Bun 文件夹名称,可以随意更改该名称。

现在,cd 进入我们的文件夹,因我们将使用 Elysia 0.3 (RC) 中的新功能,所以需要先安装 Elysia 的 RC 通道,并在这里获取一个 Cookie 插件和将来要使用的 Supabase 客户端。

bash
bun add elysia@rc @elysiajs/cookie@rc @supabase/supabase-js

让我们创建一个 .env 文件以将 Supabase 服务加载为秘密。

bash
# .env
supabase_url=https://********************.supabase.co
supabase_service_role=**** **** **** ****

您不必安装任何插件来加载环境文件,因为 Bun 默认会加载 .env 文件。

现在让我们在我们喜欢的 IDE 中打开我们的项目,并在 src/libs/supabase.ts 中创建一个文件。

ts
// src/libs/supabase.ts
import { createClient } from '@supabase/supabase-js'

const { supabase_url, supabase_service_role } = process.env

export const supabase = createClient(supabase_url!, supabase_service_role!)

就这样!设置 Supabase 和 Elysia 项目所需的一切。

现在让我们深入实现!

认证 ​

现在让我们创建一个与主文件分开的认证路由。

在 src/modules/authen.ts 中,首先为我们的路由创建大纲。

ts
// src/modules/authen.ts
import { Elysia } from 'elysia'

const authen = (app: Elysia) =>
    app.group('/auth', (app) =>
        app
            .post('/sign-up', () => {
                return 'This route is expected to sign up a user'
            })
            .post('/sign-in', () => {
                return 'This route is expected to sign in a user'
            })
    )

现在,让我们应用 Supabase 来认证我们的用户。

ts
// src/modules/authen.ts
import { Elysia } from 'elysia'
import { supabase } from '../../libs'

const authen = (app: Elysia) =>
    app.group('/auth', (app) =>
        app
            .post('/sign-up', async ({ body }) => {
                const { data, error } = await supabase.auth.signUp(body) 
                if (error) return error 

                return data.user 
                return 'This route is expected to sign up a user'
            })
            .post('/sign-in', async ({ body }) => {
                const { data, error } = await supabase.auth.signInWithPassword( 
                    body 
                ) 
                if (error) return error 
                return data.user 
                return 'This route is expected to sign in a user'
            })
    )

完成了!这就是为我们的用户创建 sign-in 和 sign-up 路由所需的一切。

但我们这里有一个小问题,您会看到,我们的路由可以接受 任何 请求体并将其放入 Supabase 参数,甚至是无效的。

所以,为了确保我们放入正确的数据,我们可以为我们的请求体定义一个 schema。

ts
// src/modules/authen.ts
import { Elysia, t } from 'elysia'
import { supabase } from '../../libs'

const authen = (app: Elysia) =>
    app.group('/auth', (app) =>
        app
            .post(
                '/sign-up',
                async ({ body }) => {
                    const { data, error } = await supabase.auth.signUp(body)

                    if (error) return error

                    return data.user
                },
                { 
                    schema: { 
                        body: t.Object({ 
                            email: t.String({ 
                                format: 'email'
                            }), 
                            password: t.String({ 
                                minLength: 8
                            }) 
                        }) 
                    } 
                } 
            )
            .post(
                '/sign-in',
                async ({ body }) => {
                    const { data, error } =
                        await supabase.auth.signInWithPassword(body)

                    if (error) return error

                    return data.user
                },
                { 
                    schema: { 
                        body: t.Object({ 
                            email: t.String({ 
                                format: 'email'
                            }), 
                            password: t.String({ 
                                minLength: 8
                            }) 
                        }) 
                    } 
                } 
            )
    )

现在我们在 sign-in 和 sign-up 中都声明了一个 schema,Elysia 将确保传入的请求体与我们声明的格式相同,从而防止无效参数传递给 supabase.auth。

Elysia 还理解该 schema,因此不需要单独声明 TypeScript 的类型,Elysia 会自动将 body 的类型设为您定义的 schema。

因此,如果您意外在将来创建了破坏性更改,Elysia 会警告您有关数据类型的信息。

我们的代码非常出色,完成了我们期待的工作,但我们可以进一步优化。

您会看到,sign-in 和 sign-up 都接受相同形状的数据,未来,您可能还会发现自己在多个路由中重复一个长 schema。

我们可以通过告诉 Elysia 记住我们的 schema 来解决这个问题,然后我们可以通过告诉 Elysia 我们要使用的 schema 的名称来使用它。

ts
// src/modules/authen.ts
import { Elysia, t } from 'elysia'
import { supabase } from '../../libs'

const authen = (app: Elysia) =>
    app.group('/auth', (app) =>
        app
            .setModel({ 
                sign: t.Object({ 
                    email: t.String({ 
                        format: 'email'
                    }), 
                    password: t.String({ 
                        minLength: 8
                    }) 
                }) 
            }) 
            .post(
                '/sign-up',
                async ({ body }) => {
                    const { data, error } = await supabase.auth.signUp(body)

                    if (error) return error
                    return data.user
                },
                {
                    schema: {
                        body: 'sign', 
                        body: t.Object({ 
                            email: t.String({ 
                                format: 'email'
                            }), 
                            password: t.String({ 
                                minLength: 8
                            }) 
                        }) 
                    }
                }
            )
            .post(
                '/sign-in',
                async ({ body }) => {
                    const { data, error } =
                        await supabase.auth.signInWithPassword(body)

                    if (error) return error

                    return data.user
                },
                {
                    schema: {
                        body: 'sign', 
                        body: t.Object({ 
                            email: t.String({ 
                                format: 'email'
                            }), 
                            password: t.String({ 
                                minLength: 8
                            }) 
                        }) 
                    }
                }
            )
    )

太好了!我们在路由中只是使用了名称引用!

TIP

如果您发现自己有一个长 schema,可以将它们声明在一个单独的文件中,并在 Elysia 的任何路由中重新使用,从而将重点放回业务逻辑上。

存储用户会话 ​

太好了,现在为完成认证系统,我们需要做的最后一件事是存储用户会话,在用户登录后,Supabase 中的令牌称为 access_token 和 refresh_token。

access_token 是一个短期有效的 JWT 访问令牌,用于在短时间内验证用户。 refresh_token 是一个一次性使用且永不过期的令牌,用于续订 access_token。所以只要我们有这个令牌,我们就可以创建一个新的访问令牌来延长我们的用户会话。

我们可以将这两个值存储在一个 cookie 中。

现在,有些人可能不喜欢将访问令牌存储在 cookie 中,可能会使用 Bearer,但为了简单起见,我们将在这里使用 cookie。

TIP

我们可以将 cookie 设置为 HttpOnly 以防止 XSS,设置为 Secure 和 Same-Site,还可以加密 cookie 以防止中间人攻击。

ts
// src/modules/authen.ts
import { Elysia, t } from 'elysia'
import { cookie } from '@elysiajs/cookie'

import { supabase } from '../../libs'

const authen = (app: Elysia) =>
    app.group('/auth', (app) =>
        app
            .use( 
                cookie({ 
                    httpOnly: true, 
                    // 如果需要 cookie 仅通过 https 发送
                    // secure: true,
                    //
                    // 如果需要 cookie 仅对同源可用
                    // sameSite: "strict",
                    //
                    // 如果希望加密 cookie
                    // signed: true,
                    // secret: process.env.COOKIE_SECRET,
                }) 
            ) 
            .setModel({
                sign: t.Object({
                    email: t.String({
                        format: 'email'
                    }),
                    password: t.String({
                        minLength: 8
                    })
                })
            })
            // 其余代码
    )

就这样,创建了 Elysia 和 Supabase 的 sign-in 和 sign-up 路由!

使用 Rest 客户端登录

刷新令牌 ​

如前所述,access_token 是短期有效的,我们可能需要时不时地续订令牌。

幸运的是,我们可以用 Supabase 的一行代码做到这一点。

ts
// src/modules/authen.ts
import { Elysia, t } from 'elysia'
import { supabase } from '../../libs'

const authen = (app: Elysia) =>
    app.group('/auth', (app) =>
        app
            .setModel({
                sign: t.Object({
                    email: t.String({
                        format: 'email'
                    }),
                    password: t.String({
                        minLength: 8
                    })
                })
            })
            .post(
                '/sign-up',
                async ({ body }) => {
                    const { data, error } = await supabase.auth.signUp(body)

                    if (error) return error
                    return data.user
                },
                {
                    schema: {
                        body: 'sign'
                    }
                }
            )
            .post(
                '/sign-in',
                async ({ body }) => {
                    const { data, error } =
                        await supabase.auth.signInWithPassword(body)

                    if (error) return error

                    return data.user
                },
                {
                    schema: {
                        body: 'sign'
                    }
                }
            )
            .get( 
                '/refresh', 
                async ({ setCookie, cookie: { refresh_token } }) => { 
                    const { data, error } = await supabase.auth.refreshSession({ 
                        refresh_token 
                    }) 
                    if (error) return error 
                    setCookie('refresh_token', data.session!.refresh_token) 
                    return data.user 
                } 
            ) 
    )

最后,将路由添加到主服务器中。

ts
import { Elysia, t } from 'elysia'

import { auth } from './modules'

const app = new Elysia()
    .use(auth) 
    .listen(3000)

console.log(
    `🦊 Elysia 正在运行在 ${app.server?.hostname}:${app.server?.port}`
)

就这样!

授权路由 ​

我们刚刚实现了用户认证,这很有趣,但现在您可能会发现自己需要对每个路由进行授权,并且在各处重复相同的代码来检查 cookie。

幸运的是,我们可以在 Elysia 中重用这个函数。

让我们通过说,假设我们可能希望用户创建一个简单的博客帖子,而其数据库架构如下:

在 Supabse 控制台中,我们将创建一个名为 'post' 的 Postgres 表,如下所示: 使用 Supabase UI 创建表,公共表名为 'post',列为 'id',主键类型为 'int8','created_at' 类型为 'timestamp',默认值为 'now()','user_id' 链接到 Supabase 的用户架构 'user.id','post' 的类型为 'text'

user_id 链接到 Supabase 生成的 auth 表,链接为 user.id,通过这种关系,我们可以创建行级安全性,只允许帖子的所有者修改数据。

将 'user_id' 字段与 Supabase 的用户架构 'user.id' 连接

现在,让我们在另一个文件夹中创建一个新的 Elysia 路由,以将代码与认证路由分开,文件路径为 src/modules/post/index.ts。

ts
// src/modules/post/index.ts
import { Elysia, t } from 'elysia'

import { supabase } from '../../libs'

export const post = (app: Elysia) =>
    app.group('/post', (app) =>
        app.put(
            '/create',
            async ({ body }) => {
                const { data, error } = await supabase
                    .from('post')
                    .insert({
                        // 以某种方式添加 user_id
                        // user_id: userId,
                        ...body
                    })
                    .select('id')

                if (error) throw error

                return data[0]
            },
            {
                schema: {
                    body: t.Object({
                        detail: t.String()
                    })
                }
            }
        )
    )

现在,此路由可以接受请求体并将其放入数据库中,我们需要做的唯一事情是处理授权并提取 user_id。

幸运的是,由于 Supabase 和我们的 cookies,这一切都很简单。

ts
import { Elysia, t } from 'elysia'
import { cookie } from '@elysiajs/cookie'

import { supabase } from '../../libs'

export const post = (app: Elysia) =>
    app.group('/post', (app) =>
        app.put(
            '/create',
            async ({ body }) => {
                let userId: string
                const { data, error } = await supabase.auth.getUser( 
                    access_token 
                ) 
                if(error) { 
                    const { data, error } = await supabase.auth.refreshSession({ 
                        refresh_token 
                    }) 
                    if (error) throw error 
                    userId = data.user!.id 
                } 

                const { data, error } = await supabase
                    .from('post')
                    .insert({
                        // 以某种方式添加 user_id
                        // user_id: userId,
                        ...body
                    })
                    .select('id')

                if (error) throw error

                return data[0]
            },
            {
                schema: {
                    body: t.Object({
                        detail: t.String()
                    })
                }
            }
        )
    )

太好了!现在我们可以使用 supabase.auth.getUser 从 cookie 中提取 user_id。

派生 ​

我们的代码目前运行良好,但让我们描绘一个小场景。

假设您有许多需要授权的路由,像这样,您需要提取 userId,这意味着您将拥有大量重复的代码,对吧?

幸运的是,Elysia 特别设计用于解决这个问题。


在 Elysia 中,我们有一个名为 scope 的概念。

想象一下,这就像一个 闭包,其中变量只能在一个范围内使用,或者如果您来自 Rust,它就像所有权。

在范围内声明的任何生命周期,例如 group、guard,都只会在该范围内可用。

这意味着您可以为需要授权的特定路由声明一个特定的生命周期,而其他路由则不需要。

例如,某些需要授权的路由范围,而其他则不需要。

因此,我们没有重复使用所有代码,而是定义了一次,并将其应用于您需要的所有路由。


现在,让我们将获取 user_id 的过程放入一个插件中,并将其应用于该范围内的所有路由。

让我们将此插件放在 src/libs/authen.ts 中。

ts
import { Elysia } from 'elysia'
import { cookie } from '@elysiajs/cookie'

import { supabase } from './supabase'

export const authen = (app: Elysia) =>
    app
        .use(cookie())
        .derive(
            async ({ setCookie, cookie: { access_token, refresh_token } }) => {
                const { data, error } = await supabase.auth.getUser(
                    access_token
                )

                if (data.user)
                    return {
                        userId: data.user.id
                    }

                const { data: refreshed, error: refreshError } =
                    await supabase.auth.refreshSession({
                        refresh_token
                    })

                if (refreshError) throw error

                return {
                    userId: refreshed.user!.id
                }
            }
        )

此代码尝试提取 userId,并将 userId 添加到路由的 Context 中,否则将抛出错误并跳过处理程序,防止无效错误被放入我们的业务逻辑,即 supabase.from.insert。

TIP

我们也可以使用 onBeforeHandle 创建自定义验证,以便在进入主处理程序之前进行验证,而 .derive 则会执行相同的操作,任何从 derive 返回的内容都会添加到 Context 中,而 onBeforeHandle 则不会。

从技术上讲,derive 使用 transform 作为底层机制。

只需一行代码,我们就可以将所有路径都应用到该作用域内,并以类型安全的方式访问 userId。

ts
import { Elysia, t } from 'elysia'

import { authen, supabase } from '../../libs'

export const post = (app: Elysia) =>
    app.group('/post', (app) =>
        app
            .use(authen) 
            .put(
                '/create',
                async ({ body, userId }) => { 
                    let userId: string
                    const { data, error } = await supabase.auth.getUser( 
                        access_token 
                    ) 
                    if(error) { 
                        const { data, error } = await supabase.auth.refreshSession({ 
                            refresh_token 
                        }) 
                        if (error) throw error 
                        userId = data.user!.id 
                    } 

                    const { data, error } = await supabase
                        .from('post')
                        .insert({
                            user_id: userId, 
                            ...body
                        })
                        .select('id')

                    if (error) throw error

                    return data[0]
                },
                {
                    schema: {
                        body: t.Object({
                            detail: t.String()
                        })
                    }
                }
            )
    )

太好了!我们在代码中根本看不到处理授权的部分,简直像魔法一样。

将我们的注意力重新放回核心业务逻辑中。

使用 Rest 客户端创建帖子

非授权作用域 ​

现在让我们再创建一个路由,从数据库中获取帖子。

ts
import { Elysia, t } from 'elysia'

import { authen, supabase } from '../../libs'

export const post = (app: Elysia) =>
    app.group('/post', (app) =>
        app
            .get('/:id', async ({ params: { id } }) => { 
                const { data, error } = await supabase 
                    .from('post') 
                    .select() 
                    .eq('id', id) 
                if (error) return error 
                return { 
                    success: !!data[0], 
                    data: data[0] ?? null
                } 
            }) 
            .use(authen)
            .put(
                '/create',
                async ({ body, userId }) => {
                    const { data, error } = await supabase
                        .from('post')
                        .insert({
                            // 以某种方式添加 user_id
                            // user_id: userId,
                            ...body
                        })
                        .select('id')

                    if (error) throw error

                    return data[0]
                },
                {
                    schema: {
                        body: t.Object({
                            detail: t.String()
                        })
                    }
                }
            )
    )

我们使用 success 来指示帖子是否存在。 使用 Rest 客户端通过 ID 获取帖子

如果不存在,我们将返回 success: false 和 data: null。 使用 Rest 客户端尝试获取帖子的 ID 但失败

如前所述,.use(authen) 应用于被定义在自己后面的作用域 但,这意味着在之前的语句不会受到影响,而此后则为仅限授权的路由。

最后,不要忘记将路由添加到主服务器中。

ts
import { Elysia, t } from 'elysia'

import { auth, post } from './modules'

const app = new Elysia()
    .use(auth)
    .use(post) 
    .listen(3000)

console.log(
    `🦊 Elysia 正在运行在 ${app.server?.hostname}:${app.server?.port}`
)

奖励:文档 ​

作为奖励,在我们创建的一切之后,除了逐条告诉前端开发人员外,我们可以只需一行代码为他们创建文档。

使用 Swagger 插件,我们可以安装:

bash
bun add @elysiajs/swagger@rc

然后只需添加插件:

ts
import { Elysia, t } from 'elysia'
import { swagger } from '@elysiajs/swagger'

import { auth, post } from './modules'

const app = new Elysia()
    .use(swagger()) 
    .use(auth)
    .use(post)
    .listen(3000)

console.log(
    `🦊 Elysia 正在运行在 ${app.server?.hostname}:${app.server?.port}`
)

瞧 🎉 我们为我们的 API 创建了良好定义的文档。

Elysia 生成的 Swagger 文档

如果更多细节,您不必担心会忘记 OpenAPI Schema 3.0 的规格,我们还有自动补全和类型安全。

我们可以通过 schema.detail 定义路线详细信息,这也遵循 OpenAPI Schema 3.0,以便您可以妥善创建文档。 使用 `schema.detail` 的自动补全

下一步 ​

在接下来的步骤中,我们鼓励您尝试并探索 我们在本文中编写的代码,并尝试添加图像上传帖子,以进一步探索 Supabase 和 Elysia 生态系统。

如我们所见,使用 Supabase 创建一个生产就绪的 web 服务器是超级简单的,许多东西只需一行代码,非常有利于快速开发。

特别是当与 Elysia 配对时,您将获得出色的开发者体验,作为单一事实来源的声明式 schema,以及在使用 TypeScript 时创建 API 时的精心设计选择,并且作为奖励,我们可以在仅一行代码中创建文档。

Elysia 正在致力于创建一个以 Bun 为优先的 web 框架,采用新技术和新方法。

如果您对 Elysia 感兴趣,可以随时查看我们的 Discord 服务器 或访问 Elysia 在 GitHub 上。

另外,您可能还想了解 Elysia Eden,这是一个完全类型安全、无需代码生成的请求客户端,类似于 Elysia 服务器的 tRPC。

← Elysia: Ergonomic Framework for Humans