一个 Cloudflare Worker 项目杂记
介绍 TS 和 hono 的一些内容, 以及基于 ts + hono 在 worker 上实现用户认证和 stripe 对接等内容.
- https://www.typescriptlang.org/docs/handbook
- https://github.com/honojs/examples
- https://github.com/w3cj/hono-open-api-starter
TS 的一些基本内容
基础类型:
string
: 字符串number
: 整数boolean
: 布尔值
复合类型:
Array<T>
: 数组- 还可使用语法如:
number[]
(等价于Array<number>
),string[]
等
- 还可使用语法如:
object
: 对象- 在变量上标记类型, 可使用:
let myName = "Alice";
, 若忽略类型, 则类型推断情况下:let myName = "Alice"; // 推断为 string 类型
函数
非匿名:
function greet(name: string): number {
// ...
}
匿名:
const names = ["Alice", "Bob", "Eve"];
names.forEach(function (s) {
console.log(s.toUpperCase());
});
// 或
name.forEach((s) => {
console.log(s.toUpperCase());
});
区别在若使用 function
关键字, 则接大括号, 否则直接参数表接 =>
箭头和大括号.
对象可选成员的标记语法:
function printName(obj: { first: string; last?: string }) {
// ...
}
printName({ first: "Bob" });
printName({ first: "Bob", last: "Alisson" });
组合类型:
function printId(id: number | string) {
console.log("your id is: " + id);
}
// 可以定义别名:
type ID = number | string;
function printId(id: ID) {
console.log("your id is: " + id);
}
// 类型分隔符可以在第一个元素前出现, 这样在多种类型组合时更易读:
function printTextOrNumberOrBool(
textOrNumberOrBool: string | number | boolean,
) {
console.log(textOrNumberOrBool);
}
Hono + TypeScript 一些文档
- 创建工程: https://hono.dev/docs/getting-started/cloudflare-workers
- 路由: https://hono.dev/docs/api/routing
- Middleware 执行顺序是洋葱而非管线: https://hono.dev/docs/guides/middleware#execution-order
- Hono 的最佳实践: https://hono.dev/docs/guides/best-practices
- Homo Middleware(三方): https://github.com/honojs/middleware
- worker 官方例子: https://developers.cloudflare.com/workers/tutorials/
- worker 社区例子: https://developers.cloudflare.com/developer-spotlight/tutorials/
示例工程回顾
需求:
- 支持 SaaS 业务: 单独服务
- Authentication: 用户注册登录(包含三方认证), 找回密码, 修改密码等
- Authorization: 用户管理, 角色定义, 访问权限控制等
- 支付: 对接 Stripe, 进行终身/订阅支付和管理
- 支持产品业务功能: 单独服务
- 产品激活
- 产品业务功能相关服务
- 用户产品管理: 单独服务
- 用户自己的控制台管理用量/余额等
业务需求分解:
待续...
概述
此工程是对如下技术栈的一个结合使用实验:
- 平台: Cloudflare Worker
- ORM: Prisma
- 数据库: Cloudflare D1
- Web 框架: hono
- 语言: typescript
- 数据验证: zod
- 测试: vitest
- API 文档: swagger
- 特殊: jwt 在 worker 环境下的实现
- 特殊: just 作为命令运行器, 简化工程维护工作
- 待补充...
同时在工程中进行:
- 用户认证和授权, 角色管理
- stripe 对接
- 待补充...
上述内容的实现和验证.
工程初始化
-
对照 cloudflare worker 文档创建工程, 这里使用 yarn:
yarn create cloudflare
-
使用 biome 进行 lint 和 format, 配置如下:
{
"$schema": "https://biomejs.dev/schemas/1.9.3/schema.json",
"vcs": {
"enabled": false,
"clientKind": "git",
"useIgnoreFile": false
},
"files": {
"ignoreUnknown": false,
"ignore": []
},
"formatter": {
"enabled": true,
"indentWidth": 2,
"lineWidth": 120,
"indentStyle": "space"
},
"organizeImports": {
"enabled": true
},
"linter": {
"enabled": true,
"rules": {
"recommended": true
}
},
"javascript": {
"formatter": {
"quoteStyle": "single",
"semicolons": "asNeeded"
}
}
} -
package.json 如下:
{
"name": "worker-ts-1",
"version": "0.0.0",
"private": true,
"scripts": {
"deploy": "wrangler deploy",
"dev": "wrangler dev",
"start": "wrangler dev",
"test": "vitest",
"cf-typegen": "wrangler types"
},
"devDependencies": {
"@biomejs/biome": "1.9.3",
"@cloudflare/vitest-pool-workers": "^0.5.2",
"@cloudflare/workers-types": "^4.20240925.0",
"prisma": "^5.20.0",
"typescript": "^5.5.2",
"vitest": "2.0.5",
"wrangler": "^3.79"
},
"dependencies": {
"@hono/swagger-ui": "^0.4.1",
"@hono/zod-openapi": "^0.16.4",
"@hono/zod-validator": "^0.4",
"@prisma/adapter-d1": "^5.20.0",
"@prisma/client": "^5.20.0",
"@tsndr/cloudflare-worker-jwt": "^3.1.2",
"hono": "^4.6.3",
"zod": "^3.23.8"
}
} -
如果需要在 vscode 中 debug, 则参考如下配置 launch.json 即可:
-
.vscode/launch.json
添加内容, 详见: https://blog.cloudflare.com/debugging-cloudflare-workers/ -
添加后, 在终端运行
yarn dev
-
然后再在 VSCode 中启动调试, 此时即可调试除了启动过程外的内容了...(这个过程实际是将 Debugger 附加到对应进程上)
-
如果不是 wsl, 则可以直接终端按
d
然后在 chrome 中进行调试 -
内容参考:
{
"configurations": [
{
"name": "Wrangler",
"type": "node",
"request": "attach",
"port": 9229,
"cwd": "/",
"resolveSourceMapLocations": null,
"attachExistingChildren": false,
"autoAttachChildProcesses": false
}
]
}
-
-
附上
justfile
:# 创建新的迁移
new-mg mgname:
npx wrangler d1 migrations create worker-ts-1 {{mgname}}
# 生成迁移 sql
gen-mg-sql out:
npx prisma migrate diff \
--from-local-d1 \
--to-schema-datamodel ./prisma/schema.prisma \
--script \
--output {{out}}
# 执行迁移
exec-mg-local:
npx wrangler d1 migrations apply worker-ts-1 --local
# 安装依赖:
id:
yarn
# 更多... -
同时提供一个脚本便于协作开发时环境初始化:
#!/bin/bash
# 脚本执行前, 需要创建对应的 D1 数据库, 名字为 worker-ts-1, 且已将 ID 添加到 wrangler.toml 中.
yarn
# npx wrangler login # login 后才能执行远程
# cargo install just
npx wrangler d1 migrations apply worker-ts-1 --local
npx wrangler d1 migrations apply worker-ts-1 --remote
npx prisma generate
验证 JWT 在 worker 上的处理
- 安装 bun
bun create hono@latest my-app
选择 cloudflare worker 模板.- 进入工程后, 使用
bun run dev
即可运行.
建立测试
参考:
- https://developers.cloudflare.com/workers/testing/vitest-integration/get-started/write-your-first-test/
- https://hono.dev/docs/getting-started/cloudflare-workers
-
安装必要依赖:
bun add --dev vitest @cloudflare/vitest-pool-workers
-
在 package.json 中添加:
"test": "vitest --run"
-
添加测试配置文件(根目录下) vitest.config.ts:
import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: "./wrangler.jsonc" },
},
},
},
}); -
新建
test
目录, 在其中创建对应tsconfig.json
配置文件:{
"extends": "../tsconfig.json",
"compilerOptions": {
"moduleResolution": "bundler",
"types": [
"@cloudflare/workers-types/experimental",
"@cloudflare/vitest-pool-workers"
]
},
"include": ["./**/*.ts", "../src/env.d.ts"]
} -
创建完成后, 开始编写测试代码, 新建一个
index.test.ts
(默认 vitest 会找目录中所有的.test
或.spec
)文件:import { describe, expect, it } from "vitest";
import app from "../src";
describe("测试 Home", () => {
it("应正确响应信息", async () => {
const res = await app.request("http://localhost/");
expect(await res.text()).toBe("nothing here!");
});
}); -
执行测试:
bun run test
. -
添加一个 api 后, 可以继续测试:
app.get("/", (c) => {
return c.text("nothing here!");
}).get("/404", (c) => {
return c.text("Not Found", 404);
});
// ...
// index.test.ts
import { describe, expect, it } from "vitest";
import app from "../src";
const FAKE_BASE_URL = "http://localhost";
function apiEndpoint(path: string): string {
return FAKE_BASE_URL + path;
}
describe("测试 Home", () => {
it("应正确响应信息", async () => {
const res = await app.request(apiEndpoint("/"));
expect(await res.text()).toBe("nothing here!");
});
it("请求 /404 应响应 404", async () => {
const res = await app.request(apiEndpoint("/404"));
expect(res.status).toBe(404);
expect(await res.text()).toBe("Not Found");
});
});
使用 jose
- https://github.com/panva/jose
- https://github.com/search?q=import+*+as+jose+from+%27jose%27+language%3ATypeScript&type=code&l=TypeScript
- 参考:
- https://github.com/rocicorp/mono/blob/030cf1921597138bfdf0479725ee4a950db604c4/packages/zero-cache/src/auth/jwt.test.ts
- https://github.com/rocicorp/mono/blob/030cf1921597138bfdf0479725ee4a950db604c4/packages/zero-cache/src/auth/jwt.ts
- https://github.com/panva/jose/blob/main/docs/key/generate_key_pair/functions/generateKeyPair.md
- demo 示例 API: https://worker-jwt-hoho.showgpray.workers.dev/jwk-pair
工程结构
工程整体逻辑结构分解为:
- API 表现层: 提供面向用户服务的最外层
- 业务逻辑层: 纯 TS 层的业务逻辑实现, 包含 DTO 定义/验证逻辑, helper 函数, DTO 和 Entity 相互转换等.
- 数据访问层: 和 D1 数据库通信, 实现数据持久化和对外通信, 包含 Entity 定义
在工程中目录组织上, 使用功能模块形式的目录分解, 也便于后续转化为一个个的小服务 worker.
实现详解(待续...)
首先工程顶层目录结构类似如下:
$ tree -L 1
.
├── README.md
├── biome.json # biome 配置
├── bootstrap.sh # 初始化脚本
├── doc # 文档目录
├── justfile # just 配置
├── migrations # 数据库 schema 迁移目录
├── package.json
├── prisma # prisma schema 定义目录
├── src # 源文件
├── test # 单元测试/集成测试
├── tsconfig.json
├── vitest.config.ts
├── worker-configuration.d.ts
├── wrangler.toml
└── yarn.lock
表现层
程序入口: index.ts
在 wrangler.toml
中指定的 main 入口为 main = "src/index.ts"
.
不同 API Group 通过 Hono 路由组织, 类似如下:
app.get("/swagger", onlyRunsOnDevEnv, swaggerUI({ url: "/doc" }));
app.route("/github", github);
app.route("/up", userpost);
app.route("/auth", auth);
业务逻辑层
数据访问层
此处主要关注如下问题:
- 如何在 worker 环境下正确使用 prisma
- D1 数据库使用和迁移
关于 "Prisma 的 Client 数量问题", 详见官方文档 -- "the-number-of-prismaclient-instances-matters"
基础设施
基础设施完全使用 Cloudflare 提供的, 在 KV 存放私有键值对, D1 数据库持久化数据.
D1 存放生成的 Jwk Pair
使用 D1 存放生成的 Jwk 对, 以支持不同的客户端请求验证, 同时可将公钥提供给客户. 由于公钥可能请求频率较高, 因此会在 KV 中同时使用一个键进行存放.
客户端不进行公钥持久化, 每次会请求服务器提供, 给出自己的 APP ID 获取公钥.
- 根据官方文档, 安装必要依赖, 以及 prisma + d1 初始化的步骤: https://developers.cloudflare.com/d1/tutorials/d1-and-prisma-orm/
- 迁移时, 参考 prisma 文档步骤进行: https://www.prisma.io/docs/orm/overview/databases/cloudflare-d1
- prisma 在 cf worker 上的考虑: https://www.prisma.io/docs/orm/prisma-client/deployment/edge/deploy-to-cloudflare#general-considerations-when-deploying-to-cloudflare-workers
- cf 提供的 prisma 使用示例: https://developers.cloudflare.com/d1/tutorials/
标准步骤:
- 可选: 初始化(创建 schema, 创 建首次迁移, 应用首次迁移)
- 修改 schema
- 生成迁移文件: create migrations
- 生成迁移文件内容: generate sql
- 应用迁移: apply
- prisma client 再生成: generate client
Authentication
起步参考
- https://authjs.dev/getting-started/installation
- https://github.com/nextauthjs/next-auth
- https://github.com/mackenly/auth-js-d1-example
- https://developers.cloudflare.com/developer-spotlight/tutorials/fullstack-authentication-with-next-js-and-cloudflare-d1/
在工程中, 用到如下依赖或服务:
- Auth.js
- Resend
服务的 API key 均存放到 CF 的 Variable 中以便使用.
步骤
添加 Auth.js 依赖:
# 修改为使用 bun 且兼容 hono
bun add @hono/auth-js @auth/core @auth/d1-adapter
这里使用的是社区中间件 https://github.com/honojs/middleware/blob/main/packages/auth-js/README.md
github 示例项目: https://github.com/divyam234/next-auth-hono-react
若不使用其他框架, 可以看 hono 的 bearer authentication 文档: https://hono.dev/docs/middleware/builtin/bearer-auth
实现时的一些考虑: https://dev.to/tbroyer/beyond-the-login-page-4hjd
谷歌登录的简化流程: https://community.cloudflare.com/t/hono-google-auth/623626/4
谷歌登录页的前端代码生成: https://developers.google.com/identity/gsi/web/tools/configurator
cuid2 作为用户的 ID 是可行的: https://www.prisma.io/docs/orm/reference/prisma-schema-reference#cuid
- cuid2: https://github.com/paralleldrive/cuid2 参考:
- https://github.com/reworkd/AgentGPT/blob/main/next/prisma/schema.prisma
- enum 的定义: https://www.prisma.io/docs/orm/reference/prisma-schema-reference#enum
- 一个典型的后端 schema 定义(包含所需业务): https://github.com/podkrepi-bg/api/blob/bdc108bee8cb02d51361844f4018e4e6dfbe1b96/schema.prisma#L4
- https://github.com/CapestoneGROUP6/Backend/blob/e13c4c327a8edcd8bb83626402691b6f9e2fc0ba/Schema.prisma#L4
- 精简可用定义 AgentGPT: https://github.com/reworkd/AgentGPT/blob/main/next/prisma/schema.prisma
- 一个家具城后端 DB 定义: https://github.com/SrivastavaSrijan/Lilac-Furniture-Store-Backend/blob/9135a554c9d92f5975587e7c0d82273c60a6d7af/schema.prisma#L4
生成 auth secret:
npx auth secret
注册和配置需要的 OAuth Provider, 比如谷歌, 苹果等.
用户注册登录所需功能
- 用户名密码注册, 需要邮箱验证, 需要密码hash
- 用户名密码登录
- 谷歌/苹果 OAuth 登录
- 忘记密码
- 修改密码, 需要邮箱验证
- 购买后自动创建用户 -> 首次登录时发送邮件链接 -> 链接中修改密码 -> 成功登录. 或在三方登录时使用相同邮箱情况下确认是 Provider 下发, 且对应应用和服务, 则可以进行创建.
数据库表设计
参考: https://vertabelo.com/blog/user-authentication-module/
External Provider 示例
- Hono + google auth 示例代码: https://gist.github.com/rishi-raj-jain/ef5f20f61423e7fd81d2ea32b26f56a5
- Hono OAuth Middleware:https://github.com/honojs/middleware/tree/main/packages/oauth-providers
- Lucia Auth library 学习用途, 不在生产环境下使用: https://github.com/lucia-auth/lucia
测试
d1, kv, r2 等服务在本地测试中作为外部依赖使用时(通过依赖替换模拟业务依赖), 可以参考官方仓库的写法:
- https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/d1
- 官方博客文章-测试引导: https://blog.cloudflare.com/workers-vitest-integration/
- 关于 d1 的测试: https://github.com/cloudflare/workers-sdk/tree/main/fixtures/vitest-pool-workers-examples/d1
D1 在测试环境中的使用
参考官方给出的测试示例.
-
添加 node 类型, 在
vitest.config.ts
中使用 path:bun add -d @types/node
-
vitest.config.ts
修改为如下:import {
defineWorkersConfig,
readD1Migrations,
} from "@cloudflare/vitest-pool-workers/config";
import path from "node:path";
export default defineWorkersConfig(async () => {
// 迁移文件路径
const migrationsPath = path.join(__dirname, "migrations");
// 读取所有迁移
const migrations = await readD1Migrations(migrationsPath);
return {
test: {
// 在测试目录中的迁移应用函数
setupFiles: ["./test/apply-migrations.ts"],
poolOptions: {
workers: {
wrangler: { configPath: "./wrangler.jsonc" },
miniflare: {
kvNamespaces: ["TEST_NAMESPACE"],
// 将刚才生成的所有迁移绑定到一个 TEST_MIGRATIONS 上
bindings: { TEST_MIGRATIONS: migrations },
},
},
},
},
};
}); -
下一步是两个文件的处理, 目的是让所有迁移能够在测试中被访问到:
- test 目录中添加一个
env.d.ts
:
declare module "cloudflare:test" {
// Controls the type of `import("cloudflare:test").env`
interface ProvidedEnv extends Env {
TEST_MIGRATIONS: D1Migration[]; // Defined in `vitest.config.mts`
}
}- test 目录中添加一个
apply-migrations.ts
:
import { applyD1Migrations, env } from "cloudflare:test";
// Setup files run outside isolated storage, and may be run multiple times.
// `applyD1Migrations()` only applies migrations that haven't already been
// applied, therefore it is safe to call this function here.
await applyD1Migrations(env.DATABASE, env.TEST_MIGRATIONS);此时, 每次执行测试, 都会先运行 "应用迁移", 且由于被测代码使用的是对应的
env.DATABASE
, 因此迁移应用成功后, 也就能够正确对数据库进行处理了. - test 目录中添加一个
其它
- 关于 CI 使用 wrangler 部署: 由于没有可视化登录界面, 因此需要手动指定两个值(即
CLOUDFLARE_API_TOKEN
和CLOUDFLARE_ACCOUNT_ID
). 在终端中 export 后, 只需带上即可. 一个简单的做法是:
- 先在 .zshrc 中添加二者对应的 export
- 修改部署脚本, 在 wangler 前加上字段对应的环境变量. 比如:
"deploy": "yarn build ; CLOUDFLARE_ACCOUNT_ID=$CLOUDFLARE_ACCOUNT_ID CLOUDFLARE_API_TOKEN=$CLOUDFLARE_API_TOKEN npx wrangler pages deploy ./build --project-name xxx --branch xxx",
- biome 配置文档: https://biomejs.dev/reference/configuration/#json
- biome zed 配置: https://biomejs.dev/reference/zed/#installation
具体实现记录
- prisma 使用 sqlite connector 时, 无法在 Schema 中定义固定长度的 String, 比如
@db.VarChar(50)
这类的如何处理? - 如果在 schema 定义中无法处理, 则只能通过上层业务逻辑进行限制, 对输入严格把控