skip to content
Honwhy Wang

cloudflare workers 全栈化实战

/ 20 min read

经过多次的调研,本人打算利用cloudflare workers 实现一个全栈化项目的部署,技术选型前端使用Vue,后端除了cloudflare workers全家桶,还是用Honobetter-auth以及drizzle

初始化框架

搭建全栈化项目

npm create cloudflare@latest -- cards --framework=vue

初始化后,整个工程已经配置好vite.config.ts, 规划好前端代码部分在src目录 以及后端代码部分在server目录。 整个项目可以看作以前端项目为主体,后端项目为辅的设计。

引入better-auth

这一步的目的是使用better-auth的账号及登录方面的表设计。主要涉及的表有 user account verification,另外和会话相关的我们开启次级存储,使用cloudflare workersKV。同时,better-auth 提供了完整的账号管理功能,以及集成第三方登录的能力。另外它也提供了前端使用的SDK。

配置better-auth 密钥,这一步和密码hash 有关,具体源码没有深究

BETTER_AUTH_SECRET=

如果是普通的数据库,不像cloudflare workers 这样子需要首先bindging,然后通过http 接口触发,再从context 中获取,那么配置使用better-auth 挺简单的。

配置好DB,

import { betterAuth } from "better-auth";
import { drizzleAdapter } from "better-auth/adapters/drizzle";
import { db } from "@/db"; // your drizzle instance
export const auth = betterAuth({
database: drizzleAdapter(db, {
provider: "pg", // or "mysql", "sqlite"
}),
});

使用cli 工具,npx @better-auth/cli generate 生成schema,npx @better-auth/cli migrate 在数据库中创建表。

由于cloudflare workers 的特殊性,这里采用网上的开源方案cf-script,先折衷搞到shema,

import { getAdapter } from "better-auth/db";
import { writeFile } from "node:fs/promises";
import { resolve } from "node:path";
import { initBetterAuth } from "../server/lib/auth";
import { generateDrizzleSchema } from "./_vendors/drizzle-generator";
export default async (env: unknown) => {
const betterAuth = initBetterAuth(env);
const output = await generateDrizzleSchema({
adapter: await getAdapter(betterAuth.options),
options: betterAuth.options,
file: resolve(import.meta.dirname, "../db/schema/better-auth-schemas.ts")
});
await writeFile(output.fileName, output.code ?? "");
console.log(`Better auth schema generated successfully at (${output.fileName} 🎉`);
};

然后初始化BetterAuth对象是在全局路由中处理,

server/index.ts
type Bindings = {
DB: D1Database; // Assuming your D1 binding is named 'DB'
KV: KVNamespace; // Assuming your KV binding is named 'KV'
};
// 扩展 Context 的变量类型
interface Variables {
db: ReturnType<typeof drizzle>;
}
const app = new Hono<{ Bindings: Env; Variables: Variables }>()
// 全局 middleware 示例
app.use('*', async (c, next) => {
// 这里可以做一些全局处理,比如日志、鉴权等
// console.log('Global middleware: 请求路径', c.req.path)
const db = drizzle(c.env.DB)
// console.log('Database connection established:', db)
c.set('db', db) // 挂载 db 实例到 Context
await next()
})

上面的处理,已经在全局开始设置了db 实例,然后再某些路由handler 中处理的时候,调用下面的方法就可以拿到betterAuth实例。

export const auth = (env: Env): ReturnType<typeof betterAuth> => {
const db = drizzle(env.DB);
return betterAuth({
...betterAuthOptions,
database: drizzleAdapter(drizzle(env.DB), {
provider: "sqlite",
schema: {
user: schema.userTable,
account: schema.accountTable,
session: schema.sessionTable,
verification: schema.verificationTable,
},
}),
secondaryStorage: {
get(key) {
return env.KV.get(key);
},
set(key, value, ttl) {
return env.KV.put(key, value, { expirationTtl: ttl });
},
delete(key) {
return env.KV.delete(key);
}
},
baseURL: env.BETTER_AUTH_BASE_URL,
secret: env.BETTER_AUTH_SECRET,
emailAndPassword: {
enabled: true
},
// Additional options that depend on env ...
});
};

比如说登录,

authRoutes.post('/login', async (c) => {
const db = c.get('db') // 确保 db 实例已挂载到 Context
const requestBody = await c.req.json();
const JWT_SECRET = c.env.JWT_SECRET!;
try {
const { email, password, username } = requestBody;
// 支持email或username字段
const loginIdentifier = email || username;
// 验证输入
if (!loginIdentifier || !password) {
return c.json({
success: false,
message: '邮箱/用户名和密码都是必填项'
}, 400);
}
const resp = await auth(c.env).api.signInEmail({
body: {
email: requestBody.email,
password: requestBody.password,
}
})
if (resp && resp.user) {
const user = resp.user;
// 生成JWT token
const token = await generateToken(JWT_SECRET, user.id);
// 获取新创建的用户
const existingUser = await db.select().from(schema.userTable).where(eq(schema.userTable.id, user.id)).limit(1).get();
return c.json({
success: true,
message: '登录成功',
data: {
user: {
id: user.id,
username: user.name,
email: user.email,
},
token
}
});
}
return c.json({
success: false,
message: '用户名或密码错误'
}, 400);
} catch (error) {
console.error('Login error:', error);
return c.json({
success: false,
message: '登录失败,请稍后重试',
error,
}, 500);
}
});

使用betterAuth实例的API方法比自己手写各类处理:校验账密、创建会话要简单多了。betterAuth提供了丰富的API,比如登录 signInEmail, 注册signUpEmail 等等。

Hono

可以看出前面演示的代码使用了Hono,他比原生的cloudflare workers 写起来更加舒服一点,各方面支持都非常完善,所以建议采用这个实现路由管理。它的中间件设计和express 是可以类比的,这在拦截请求判断是否登录授权用户方面非常方便。

server/middlewared/auth.ts
// 验证JWT token的中间件
export const authenticateToken = async (c: Context, next: Next) => {
const db = c.get('db'); // 确保 db 实例已挂载到 Context
// JWT 密钥,建议从环境变量中加载
const JWT_SECRET = c.env.JWT_SECRET!;
try {
const authHeader = c.req.header('Authorization');
const token = authHeader?.split(' ')[1]; // Bearer TOKEN
if (!token) {
return c.json(
{
success: false,
message: '访问被拒绝,需要提供认证令牌',
},
401
);
}
if (!JWT_SECRET) {
return c.json(
{
success: false,
message: '服务器配置错误:JWT 密钥未定义',
},
500
);
}
// 验证 token
const decodedPayload = await verifyJWT(token, JWT_SECRET) as { userId: string }
// 从数据库获取用户信息
const user = await db.select().from(schema.userTable).where(eq(schema.userTable.id, decodedPayload.userId)).limit(1).get();
if (!user) {
return c.json(
{
success: false,
message: '无效的认证令牌',
},
401
);
}
// 将用户信息添加到 Hono 的上下文对象中
// 这样在后续的路由或中间件中,你可以通过 c.get('user') 来访问它
c.set('user', user);
await next();
} catch (error) {
console.error('Authentication error:', error);
// 根据不同的错误类型返回不同的响应
if (error instanceof ExpiredTokenError) {
return c.json(
{
success: false,
message: '认证令牌已过期',
},
401
);
} else if (error instanceof InvalidSignatureError) {
return c.json(
{
success: false,
message: '无效的认证令牌',
},
401
);
}
// 捕获其他未知错误
return c.json(
{
success: false,
message: '服务器内部错误',
},
500
);
}
};

Hono 还有一个路由分组的做法,grouping,这个可以让我们把整个better-auth 相关的功能使用一个前缀管理起来。

import { Hono } from "hono";
import { auth } from "./auth";
const app = new Hono();
app.route('/api/auth', async (c) => {
return auth.handler(c.req.raw);
})

然后前端调用/api/auth/sign-in/email 实现登录。

drizzle

drizzle的使用很自然地嵌入到整个Hono handler代码中了,在全局拦截器中已经将db跟drizzle绑定起来了。

app.use('*', async (c, next) => {
// 这里可以做一些全局处理,比如日志、鉴权等
// console.log('Global middleware: 请求路径', c.req.path)
const db = drizzle(c.env.DB)
// console.log('Database connection established:', db)
c.set('db', db) // 挂载 db 实例到 Context
await next()
})

后续使用drizzle ,大致是引入schema,然后使用drizzle的语法进行查询

const db = c.get('db')
const user = await db.select().from(schema.userTable).where(eq(schema.userTable.id, decodedPayload.userId)).limit(1).get();

事务操作也是支持的。虽然普通情况下使用db.transaction 就可以开启事务了,

await db.transaction(async (tx) => {
// 删除用户账户
await tx.delete(schema.userTable).where(eq(schema.userTable.id, userId));
// 删除用户创建的卡片
await tx.delete(schema.cardsTable).where(eq(schema.cardsTable.userId, userId));
});

但是,cloudflare workers D1 比较特别,通过这篇文章whats-new-with-d1,建议采用db.batch

环境变量管理

虽然(官方文档)[https://developers.cloudflare.com/workers/configuration/environment-variables/] 已经讲解得很详细了,但是我这里要建议是一种恰当的做法。

在开发环境 使用.env 配置文件,并且这个配置文件千万不要提交到代码仓库。

BETTER_AUTH_SECRET=
BETTER_AUTH_BASE_URL=http://localhost:5173
DRIZZLE_ACCOUNT_ID=
DRIZZLE_DATABASE_ID=
DRIZZLE_TOKEN=
## jwt
JWT_SECRET=
JWT_EXPIRES_IN=
##
VITE_API_BASE_URL=/api

在生产环境建议通过dashboard 配置环境变量。 由于cloudflare workers 会在部署的时候把wrangler.jsoncwrangler.toml 当作唯一的来源source of truth,所以要调整相关配置

keep_vars: true

不然执行wrangler deploy的话,在dashboard 配置的变量会覆盖和删除。

jwt

虽然使用了Hono ,但是Hono 自带的jwt 方案在cloudflare 环境执行会由于缺少相关依赖而得不到支持。因此,建议自行实现。

export const signJWT = async (payload: Record<string, unknown>, secret: string, expiresIn = '7d') => {
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false,
['sign']
);
const header = {
alg: 'HS256',
typ: 'JWT'
};
// 计算过期时间
const exp = Math.floor(Date.now() / 1000) + (expiresIn === '7d' ? 7 * 24 * 60 * 60 : 3600); // 默认为7天过期
const payloadWithExp = { ...payload, exp };
const headerBase64 = base64UrlEncode(JSON.stringify(header));
const payloadBase64 = base64UrlEncode(JSON.stringify(payloadWithExp));
// 签名
const data = `${headerBase64}.${payloadBase64}`;
const signatureBuffer = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(data));
const signatureBase64 = base64UrlEncode(String.fromCharCode(...new Uint8Array(signatureBuffer)));
return `${data}.${signatureBase64}`;
};
// 编码
const base64UrlEncode = (str: string) => {
return btoa(String.fromCharCode(...new TextEncoder().encode(str)))
.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
};
export const verifyJWT = async (token: string, secret: string) => {
const [headerBase64, payloadBase64, signatureBase64] = token.split('.');
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: { name: 'SHA-256' } },
false,
['verify', 'sign']
);
const data = `${headerBase64}.${payloadBase64}`;
// 解码签名
const signatureBuffer = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(data)
);
const computedSignatureBase64 = base64UrlEncode(String.fromCharCode(...new Uint8Array(signatureBuffer)));
// 验证签名是否匹配
if (computedSignatureBase64 !== signatureBase64) {
throw new InvalidSignatureError('Invalid signature');
}
// 验证过期时间
const payload = JSON.parse(atob(payloadBase64));
const currentTime = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < currentTime) {
throw new ExpiredTokenError('Token has expired');
}
return payload;
};

邮件功能

邮件功能是网站注册功能必要的一环,用于验证用户的邮箱。集成better-auth 后,接入邮件发送和token 验证的功能都具备了,但是要在我们项目中执行起来还需要一翻调整。

接入Resend

发送邮件的能力我们采用Resend 服务。首先需要注册Resend 服务,然后在Resend 服务中添加域名,如果你的域名刚好是在cloudflare 上管理的,那么通过授权给ResendResend 会自动在cloudfalre 上添加相关域名配置。

添加域名,

配置DNS MX 记录 和TXT 记录,

在better-auth 中集成

首先需要开启邮箱验证功能,参考文档 require-email-verification

emailVerification: {
sendVerificationEmail: async ({ user, token }) => {
await sendEmail(user.email, token, env.BETTER_AUTH_BASE_URL, env.RESEND_API_KEY);
},
sendOnSignUp: true,
autoSignInAfterVerification: true,
expiresIn: 3600 * 24 // 24 hour
},

关于sendOnSign

之所以强调这个选项,因为这个选项才是真正开启邮箱验证功能的关键。 官方的文档 require-email-verification

export const auth = betterAuth({
emailAndPassword: {
requireEmailVerification: true,
},
});

并没有实际的作用(疑似bug 或者 项目组装起来导致的)。

正确的解法,还是设置sendOnSignUp: true,但是官方提示这个做法会在每次signUpEmail 都会发送一次邮件。 解决办法就是回归到业务逻辑,控制一个邮件不被注册两次。

实现sendEmail 方法

由于是在cloudflare workers 环境中执行,因此无法像普通nodejs 项目那样从process.env.RESEND_API_KEY 获取到Resend 的 apikey,这里选择从better-auth options 配置中传入env.RESEND_API_KEY 变量。

一个简单的邮件发送功能如下,

import { Resend } from "resend"
export async function sendEmail(to: string, token: string, BETTER_AUTH_BASE_URL: string, RESEND_API_KEY: string) {
const resend = new Resend(RESEND_API_KEY);
const emailHtml = `<a href="${BETTER_AUTH_BASE_URL}/verify?token=${token}" >点击验证您的邮箱</a>`;
const { data, error } = await resend.emails.send({
to,
subject: "验证您的邮箱",
html: emailHtml,
});
if (error) {
console.error("Failed to send email", error);
throw new Error("Email send failed");
}
console.log("Email sent", data);
return data;
}

但是这样子体验效果很差,最好通过AI 将邮件的正文重写处理成富文本形式,搭建一个简洁美观的邮件正文。

验证token

点击上面的邮箱验证地址${BETTER_AUTH_BASE_URL}/verify?token=${token} 并不会里面请求后台接口,而是首先尝试打开前端项目,在浏览器页面打开的任何链接首先被这个项目Vue 路由管理接管了。 因此,我们需要增加一个前端路由及Vue页面,/verify -> Verify.Vue,然后在这个Vue 页面中请求后台接口。

后台接口实现逻辑比较简单,从cloudflare workers 上下文中拿到better-auth 实例,再调用它的verfyEmail api能力。

const result = await c.var.auth.api.verifyEmail({
query: { token },
});

重发邮箱验证

邮箱验证链接上的token 有效期是24小时,如果用户忘记或者没有收到,可以选择重发。考虑到重发可能会造成资源浪费,项目中做了一个一天重复次数的限制。 前端调用 /api/auth/resend-email 接口并通过用户登录态判断后,再去调用better-authsendVerificationEmail api。

// better-auth will regenerate and send a new email
const result = await c.var.auth.api.sendVerificationEmail({
body: { email: user.email }
});

图床功能

图床功能选择的是cloudflare workersR2。选择的方案是cloudflare workers binding 方式上传,通过公开的url进行访问,并搭配自定义的域名。

上传图片功能

const formData = await c.req.formData();
const file = formData.get('file') as File;
const fileKey = getFileKey(user.id, file.name);
// 上传到R2
const putResult = await c.env.R2.put(fileKey, file.stream(), {
httpMetadata: {
contentType: file.type,
},
customMetadata: {
uploadedBy: user.id,
},
});
return c.json({
success: true,
message: '文件上传成功',
fileUrl: `${c.env.R2_BUCKET_DOMAIN}/${fileKey}`
});

如果在生产环境,上传图片成功后,返回前端的是一个类似 https://i.sandural.cc/bhES2ttw9pHJvXYKKmzpo3MwDHnB/1756229492767-deepseek_mermaid_20250826_a901ed.png

在开发环境dev 模式下,图片资源并没有真正地上传到云端服务器,而是保存在.wrangler/state/v3/r2 目录下的,为了能够顺利在dev 模式回显图片,如下处理。

R2_BUCKET_DOMAIN=http://localhost:5173/images

然后增加一个后端路由/images/:key{.+} ,然后通过keyR2 对象存储中读取流并返回

const key = c.req.param("key")
const obj = await c.env.R2.get(key)
if (!obj) return c.text("Not found", 404)
return new Response(obj.body, {
headers: {
"Content-Type": obj.httpMetadata?.contentType ?? "application/octet-stream",
},
})

图片压缩功能

前端压缩使用 browser-image-compression

npm install browser-image-compression

压缩到1M 大小,

import imageCompression from 'browser-image-compression';
const options = {
maxSizeMB: 1,
maxWidthOrHeight: 1920,
useWebWorker: true,
}
const compressedFile = await imageCompression(file, options);
const newFile = new File([compressedFile], file.name, {
type: compressedFile.type,
lastModified: Date.now(),
});
// 上传到服务器
const response = await uploadApi.uploadImage(newFile);
backgroundImage.value = response.fileUrl;

后端图片压缩

这次采用jsquash 的方案, 仔细考虑后复用作者example的思路,将jpeg/jpg/png 等图片格式转换成webp 格式,这其实也是一种压缩图片的好思路。

import decodeJpeg, { init as initJpegWasm } from '@jsquash/jpeg/decode';
import decodePng, { init as initPngWasm } from '@jsquash/png/decode';
import encodeWebp, { init as initWebpWasm } from '@jsquash/webp/encode';
// @Note, We need to manually import the WASM binaries below so that we can use them in the worker
// CF Workers do not support dynamic imports
// @ts-ignore
import JPEG_DEC_WASM from "../../node_modules/@jsquash/jpeg/codec/dec/mozjpeg_dec.wasm";
// @ts-ignore
import PNG_DEC_WASM from "../../node_modules/@jsquash/png/codec/pkg/squoosh_png_bg.wasm";
// @ts-ignore
import WEBP_ENC_WASM from "../../node_modules/@jsquash/webp/codec/enc/webp_enc_simd.wasm";
let putResult = null
const extension = file.name.split('.').pop() as string
const supportedExtensions = ['jpg', 'jpeg', 'png']
if (supportedExtensions.includes(extension)) {
const imageData = await decodeImage(await file.arrayBuffer(), extension);
await initWebpWasm(WEBP_ENC_WASM);
const webpImage = await encodeWebp(imageData);
// 上传到R2
fileKey = fileKey.replace(/\.[^/.]+$/, '.webp')
putResult = await c.env.R2.put(fileKey, webpImage, {
httpMetadata: {
contentType: 'image/webp',
},
customMetadata: {
uploadedBy: user.id,
},
});
} else {
// 上传到R2
putResult = await c.env.R2.put(fileKey, file.stream(), {
httpMetadata: {
contentType: file.type,
},
customMetadata: {
uploadedBy: user.id,
},
});
}

要注意调整 vite.config.ts的配置,

export default defineConfig({
optimizeDeps: {
exclude: [
"@jsquash/png",
"@jsquash/jpeg",
"@jsquash/webp",
]
}
})

注意事项

由于采用了公开URL 访问的方式,cloudflare 提供的*.r2.dev 子域名在国内是基本无法访问的,所以必须搭配自己的独立域名,配置一个子域名即可。 另外由于公开了URL 访问,建议做好CORS控制,防止流量使用超出额度。

配置参考

疑难问题

由于better-auth 是基于session-cookie 方案来管理登录态的,如果没有按照它的要求完整支持可能会遇到问题。

比如在修改密码的时候,由于请求头中缺少Cookie信息,或者Cookie错误都会导致changePassword内部实现中获取session失败,最终导致调用API失败。

const response = await auth.api.changePassword({
body: {
currentPassword,
newPassword,
revokeOtherSessions: true,
},
headers: c.req.raw.headers,
asResponse: true,
}).catch((error) => {
console.error('Change password error:', error);
return c.json({
success: false,
message: '当前密码错误'
}, 401);
})

解决办法是在登录的时候,手动设置cookie,然后在修改密码的时候传入准确的headers。

登录时设置cookie

const response = await auth.api.signInEmail({
body: {
email: requestBody.email,
password: requestBody.password,
},
asResponse: true
})
responseCookies(c, response);
export function responseCookies(c: Context, response: Response) {
// console.log('Login response headers:', response.headers);
const cookie = response.headers.get('Set-Cookie')?.split(';')[0].split('=')[1] || ''
// console.log('Extracted cookie:', cookie);
setCookie(c, 'better-auth.session_token', cookie,
{
httpOnly: true, secure: true, sameSite: 'Lax', path: '/'
, maxAge: 7 * 24 * 60 * 60 // 7天
})
}

在修改密码时传入headers

由于cookie 中存在%特殊符号,因为需要对请求headers 进行预处理,

const cookie = decodeURIComponent(c.req.header('Cookie') || '')
const headers = new Headers(c.req.raw.headers);
headers.set('Cookie', cookie);
// ...
const response = await auth.api.changePassword({
body: {
currentPassword,
newPassword,
revokeOtherSessions: true,
},
headers: headers,
asResponse: true,
})

同时别忘记了,修改密码成功后,需要修改下cookie

responseCookies(c, response);

参考文档