介绍 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, 配置如下:
json{ "$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 如下:
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 中进行调试 -
内容参考:
json{ "configurations": [ { "name": "Wrangler", "type": "node", "request": "attach", "port": 9229, "cwd": "/", "resolveSourceMapLocations": null, "attachExistingChildren": false, "autoAttachChildProcesses": false } ] }
-
-
附上
justfile:plaintext# 创建新的迁移 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 # 更多... -
同时提供一个脚本便于协作开发时环境初始化:
bash#!/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 中添加:
json"test": "vitest --run" -
添加测试配置文件(根目录下) vitest.config.ts:
tsimport { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config"; export default defineWorkersConfig({ test: { poolOptions: { workers: { wrangler: { configPath: "./wrangler.jsonc" }, }, }, }, }); -
新建
test目录, 在其中创建对应tsconfig.json配置文件: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)文件:tsimport { 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 后, 可以继续测试:
tsapp.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修改为如下:tsimport { 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:
tsdeclare 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:
tsimport { 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 定义中无法处理, 则只能通过上层业务逻辑进行限制, 对输入严格把控