为 Cloudflare Worker 个人项目添加 better-auth 支持

2025 年 09 月 01 日

背景

Cloudflare workers 属于 serverless 的范畴,本质是利用了 cloudflare 的边缘服务器来运行一段代码。

Cloudflare workers 的运行环境被称为 v8 isolate,缺少部分 nodejs 的 API。

由于 Cloudflare 同时提供了 D1/R2/KV 等数据库或类数据库服务,因此在 Cloudflare workers 上可以实现一个轻量的后端。个人项目的使用量不大,足以免费使用。

认证是后端最常见的功能之一,而且实现起来比较复杂。better-auth 是目前最流行的认证框架(可能没有之一)。但 better-auth 默认没有直接支持 Cloudflare D1,因此无法简单整合到 Cloudflare workers 项目中。

本文实现了一个在 Cloudflare workers 上,使用 better-auth 进行认证的工程。参考:better-auth-cloudflare。读者需要对 Cloudflare workers 的开发方式有一些基础的理解(比如 wrangler CLI 的使用等)。

创建工程

执行命令(工程名明显可以自定义):

npm create cloudflare@latest -- my-first-worker

注:如果用 bun,注意 bunx 和 bun 版本是否一致。

考虑到工程需要前端,可以选择 framework starter。

另注:如果选 js 而不是 ts,在默认工程的 wrangler.jsonc 文件中的默认 main 入口点可能会出错,需手动修改。

以 vue 框架工程为例,初始目录如下:

.
├── index.html
├── jsconfig.json
├── package.json
├── package-lock.json
├── public
├── README.md
├── server
├── src
├── vite.config.js
└── wrangler.jsonc

比普通的 vue 工程只多了 server 目录和 wrangler.jsonc 文件。也就是后端接口实现。

成功创建工程后,npm installnpm run dev 即可正常调试。

创建 D1 数据库

执行命令(需自定义数据库名):

wrangler d1 create my-first-worker-db # 该命令仅生成远端数据库,不生成本地数据库

会得到一段配置,加到 wrangler.jsonc 中。注意配置中要增加 migration 配置:"migrations_dir": "drizzle"。表示让 wrangler 后续从 drizzle 目录读取 migration sql。

然后执行一条无意义的 SQL 命令来生成本地数据库(后面有任务需要),例如:

wrangler d1 execute my-first-worker-db --local --command "SELECT 1;"

注:wrangler 会把生成的本地数据库放在 .wrangler/state/v3/d1/miniflare-D1DatabaseObject/ 目录。考虑到 drizzle 配置为查找该目录下第一个 sqlite 文件,因此请注意保持该目录下的 sqlite 文件仅有一个。

添加认证功能

要使用 better-auth 来添加认证功能,需要按照后端和前端来考虑问题。其本质是,better-auth 为后端提供 /api/auth/** 的服务端 API,然后在前端提供对应的客户端 API。

后端部分:better-auth 适配 D1 的初始化方法

better-auth 提供了一个 CLI,该 CLI 通过 auth.ts 文件(参考官方文档的 Database 章节)来生成数据库的 schema/migration。

但是,由于 D1 仅可在 Cloudflare workers 环境下访问,而 better-auth 的 CLI 需要在 node.js 环境下工作。两者明显不是一套环境。因此要用一种变通方法实现 auth.ts/auth.js,也就是同时适配 worker 环境和 node.js 环境。

数据库选择 drizzle-orm 来适配 D1,首先需要实现以下几个文件:

  • server/auth.js:为后端创建认证中间件。
  • server/db/index.ts:数据库相关代码。
  • server/db/schema.ts:drizzle 所需的数据库 schema。
  • server/db/auth-schema.ts:数据库中认证部分的 schema,可以通过命令生成。
// server/auth.js, 注意这是调试用的 betterAuth 配置,后续要根据需求调整
import { betterAuth } from 'better-auth'
import { drizzle } from 'drizzle-orm/d1'
import { drizzleAdapter } from 'better-auth/adapters/drizzle'
import { schema } from './db'

// Single auth configuration that handles both CLI and runtime scenarios
function createAuth(env) {
  // Use actual DB for runtime, empty object for CLI
  const disableSignUp = !!env?.DISABLE_SIGN_UP
  const db = env ? drizzle(env.DB, { schema, logger: true }) : {}
  return betterAuth({
    database: drizzleAdapter(db, {
      provider: 'sqlite', // or "mysql", "sqlite"
    }),
    emailAndPassword: {
      enabled: true,
      disableSignUp,
      requireEmailVerification: false,
    },
  })
}

// Export for CLI schema generation
export const auth = createAuth()

// Export for runtime usage
export { createAuth }
// server/db/index.ts
import { schema } from "./schema";

// Re-export the drizzle-orm types and utilities from here for convenience
export * from "drizzle-orm";

// Re-export the feature schemas for use in other files
export * from "./auth-schema"; // Export individual tables for drizzle-kit
export * from "./schema";
// server/db/schema.ts
import * as authSchema from './auth-schema' // This will be generated in a later step

// Combine all schemas here for migrations
export const schema = {
  ...authSchema,
  // ... your other application schemas
}

上面三个文件实现了 drizzle 以及 schema 的基础配置。另外需要注意,server/db/auth-schema.ts 文件会在后续步骤生成,在现在这个时间点,可以先生成一个空文件。

接下来,执行命令来生成 auth-schema.ts:

npx @better-auth/cli@latest generate --output ./server/db/auth-schema.ts -y

然后,需要一个 /drizzle.config.ts 来辅助生成 drizzle 的 migration 脚本。很明显,代码需要寻找本地数据库,因此需要提前创建好。

// /drizzle.config.ts
import { defineConfig } from "drizzle-kit";
import fs from "node:fs";
import path from "node:path";

function getLocalD1DB() {
    try {
        const basePath = path.resolve(".wrangler");
        const dbFile = fs
            .readdirSync(basePath, { encoding: "utf-8", recursive: true })
            .find(f => f.endsWith(".sqlite"));

        if (!dbFile) {
            throw new Error(`.sqlite file not found in ${basePath}`);
        }

        const url = path.resolve(basePath, dbFile);
        return url;
    } catch (err) {
        console.log(`Error  ${err}`);
    }
}

export default defineConfig({
    dialect: "sqlite",
    schema: "./server/db/index.ts",
    out: "./drizzle",
    ...(process.env.NODE_ENV === "production"
        ? {
              driver: "d1-http",
              dbCredentials: {
                  accountId: process.env.CLOUDFLARE_D1_ACCOUNT_ID,
                  databaseId: process.env.CLOUDFLARE_DATABASE_ID,
                  token: process.env.CLOUDFLARE_D1_API_TOKEN,
              },
          }
        : {
              dbCredentials: {
                  url: getLocalD1DB(),
              },
          }),
});

然后生成 drizzle 的 migration 并执行 migration:

npx drizzle-kit generate # 根据 /server/db/schema.ts,在 drizzle 目录下生成 migration sql

执行 sql migration,将表结构写入数据库,本地操作:

wrangler d1 migrations apply --local my-first-worker-db

现在本地数据库里应该已经能查看到对应的表结构了。然后完善 /server/index.js 入口点,注意提前安装好 hono。

import { Hono } from 'hono'
import { cors } from 'hono/cors'
import { createAuth } from './auth'

// Note: configure ALLOWED_ORIGINS in production environment, e.g. "http://example.com,https://app.example.com"

const app = new Hono()

// CORS configuration for auth routes
app.use(
  '/api/auth/**',
  cors({
    // prettier-ignore
    origin: (origin, c) => {
	  // read allowed origins from environment variable
      const allowedOrigins = (c.env.ALLOWED_ORIGINS || '').split(',').map((o) => o.trim()).filter(Boolean)
      // when ALLOWED_ORIGINS not configured, allow all
      if (allowedOrigins.length === 0) { return '*' }
      // check if origin allowed and return
	  return (origin && allowedOrigins.includes(origin)) ? origin : null
    },
    allowHeaders: ['Content-Type', 'Authorization'],
    allowMethods: ['POST', 'GET', 'OPTIONS'],
    exposeHeaders: ['Content-Length'],
    maxAge: 600,
    credentials: true,
  })
)

// Middleware to initialize auth instance for each request
app.use('*', async (c, next) => {
  const auth = createAuth(c.env)
  c.set('auth', auth)
  await next()
})

// Handle all auth routes
app.all('/api/auth/*', async (c) => {
  const auth = c.get('auth')
  return auth.handler(c.req.raw)
})

// 对于受保护接口,使用 c.get("auth") 获取认证中间件后,通过 session 确认登录状态,再执行后续操作

export default app

接下来用 curl 命令模拟测试,先模拟注册命令:

curl -X POST http://localhost:5173/api/auth/sign-up/email -H "Content-Type: application/json" -d '{"email":"[email protected]","password":"12345678","name":"Tester"}'

然后模拟登录:

curl -X POST http://localhost:5173/api/auth/sign-in/email -H "Content-Type: application/json" -d '{"email":"[email protected]","password":"12345678"}'

前端部分:使用 better-auth 完成认证

前端部分现在可以很容易的添加 better-auth 认证功能了。首先是增加一个 /src/lib/auth-client.ts

// /src/lib/auth-client.ts
import { createAuthClient } from "better-auth/vue"
export const authClient = createAuthClient({})

然后简单修改之前的 App.vue,在 <script setup> 段修改,参考下面一段代码:

import { authClient } from './lib/auth-client'
const session = authClient.useSession() // 获取 session,这是一个 Proxy 类型变量

// 修改原有的 getName 实现,比如改成下面这样:
const getName = async () => {
  if (session.value.data) { // 如果已经登录则登出
    await authClient.signOut()
    return
  }
  // 如果没有登录则用之前注册的用户登录
  await authClient.signIn.email({
    email: '[email protected]',
    password: '12345678',
    rememberMe: true,
  })
}

现在可以容易的从 session 中获取当前用户,例如这样修改原来的按钮:

<button class="green" @click="getName" aria-label="get name">Name from API is: {{ session?.data?.user?.name || "Unknown" }}</button>

上面只是一个简单示例,实际功能的完整实现要对 vue 工程进行完善。

其他

剩下的功能实现基本就跟传统开发完全一样了,前端用 vue router 扩展界面;后端添加受保护接口,使用 drizzle 扩展数据库支持并操作数据库。

开发完成后,执行数据库 migration,然后发布。

后续的 Deploy to Cloudflare 按钮功能还需要进一步研究。

注:如果只希望保留自己作为唯一用户,那么注册后可以通过环境变量 DISABLE_SIGN_UP (参考前面的例子代码),关掉注册功能。记得要重新发布一次。

Top