Skip to main content

一个 Cloudflare Worker 项目杂记

· 16 min read

介绍 TS 和 hono 的一些内容, 以及基于 ts + hono 在 worker 上实现用户认证和 stripe 对接等内容.

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 一些文档

  1. 创建工程: https://hono.dev/docs/getting-started/cloudflare-workers
  2. 路由: https://hono.dev/docs/api/routing
  3. Middleware 执行顺序是洋葱而非管线: https://hono.dev/docs/guides/middleware#execution-order
  4. Hono 的最佳实践: https://hono.dev/docs/guides/best-practices
  5. Homo Middleware(三方): https://github.com/honojs/middleware
  6. worker 官方例子: https://developers.cloudflare.com/workers/tutorials/
  7. worker 社区例子: https://developers.cloudflare.com/developer-spotlight/tutorials/

示例工程回顾

需求:

  1. 支持 SaaS 业务: 单独服务
  • Authentication: 用户注册登录(包含三方认证), 找回密码, 修改密码等
  • Authorization: 用户管理, 角色定义, 访问权限控制等
  • 支付: 对接 Stripe, 进行终身/订阅支付和管理
  1. 支持产品业务功能: 单独服务
  • 产品激活
  • 产品业务功能相关服务
  1. 用户产品管理: 单独服务
  • 用户自己的控制台管理用量/余额等

业务需求分解:

待续...

概述

此工程是对如下技术栈的一个结合使用实验:

  1. 平台: Cloudflare Worker
  2. ORM: Prisma
  3. 数据库: Cloudflare D1
  4. Web 框架: hono
  5. 语言: typescript
  6. 数据验证: zod
  7. 测试: vitest
  8. API 文档: swagger
  9. 特殊: jwt 在 worker 环境下的实现
  10. 特殊: just 作为命令运行器, 简化工程维护工作
  11. 待补充...

同时在工程中进行:

  1. 用户认证和授权, 角色管理
  2. stripe 对接
  3. 待补充...

上述内容的实现和验证.

工程初始化

  1. 对照 cloudflare worker 文档创建工程, 这里使用 yarn: yarn create cloudflare

  2. 使用 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"
    }
    }
    }
  3. 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"
    }
    }
  4. 如果需要在 vscode 中 debug, 则参考如下配置 launch.json 即可:

    1. .vscode/launch.json 添加内容, 详见: https://blog.cloudflare.com/debugging-cloudflare-workers/

    2. 添加后, 在终端运行 yarn dev

    3. 然后再在 VSCode 中启动调试, 此时即可调试除了启动过程外的内容了...(这个过程实际是将 Debugger 附加到对应进程上)

    4. 如果不是 wsl, 则可以直接终端按 d 然后在 chrome 中进行调试

    5. 内容参考:

      {
      "configurations": [
      {
      "name": "Wrangler",
      "type": "node",
      "request": "attach",
      "port": 9229,
      "cwd": "/",
      "resolveSourceMapLocations": null,
      "attachExistingChildren": false,
      "autoAttachChildProcesses": false
      }
      ]
      }
  5. 附上 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

    # 更多...
  6. 同时提供一个脚本便于协作开发时环境初始化:

    #!/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 上的处理

  1. 安装 bun
  2. bun create hono@latest my-app 选择 cloudflare worker 模板.
  3. 进入工程后, 使用 bun run dev 即可运行.

建立测试

参考:

  1. 安装必要依赖: bun add --dev vitest @cloudflare/vitest-pool-workers

  2. 在 package.json 中添加:

    "test": "vitest --run"
  3. 添加测试配置文件(根目录下) vitest.config.ts:

    import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";

    export default defineWorkersConfig({
    test: {
    poolOptions: {
    workers: {
    wrangler: { configPath: "./wrangler.jsonc" },
    },
    },
    },
    });
  4. 新建 test 目录, 在其中创建对应 tsconfig.json 配置文件:

    {
    "extends": "../tsconfig.json",
    "compilerOptions": {
    "moduleResolution": "bundler",
    "types": [
    "@cloudflare/workers-types/experimental",
    "@cloudflare/vitest-pool-workers"
    ]
    },
    "include": ["./**/*.ts", "../src/env.d.ts"]
    }
  5. 创建完成后, 开始编写测试代码, 新建一个 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!");
    });
    });
  6. 执行测试: bun run test.

  7. 添加一个 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

详见 github 项目代码.

工程结构

工程整体逻辑结构分解为:

  1. API 表现层: 提供面向用户服务的最外层
  2. 业务逻辑层: 纯 TS 层的业务逻辑实现, 包含 DTO 定义/验证逻辑, helper 函数, DTO 和 Entity 相互转换等.
  3. 数据访问层: 和 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);

业务逻辑层

数据访问层

此处主要关注如下问题:

  1. 如何在 worker 环境下正确使用 prisma
  2. D1 数据库使用和迁移

关于 "Prisma 的 Client 数量问题", 详见官方文档 -- "the-number-of-prismaclient-instances-matters"

基础设施

基础设施完全使用 Cloudflare 提供的, 在 KV 存放私有键值对, D1 数据库持久化数据.

D1 存放生成的 Jwk Pair

使用 D1 存放生成的 Jwk 对, 以支持不同的客户端请求验证, 同时可将公钥提供给客户. 由于公钥可能请求频率较高, 因此会在 KV 中同时使用一个键进行存放.

客户端不进行公钥持久化, 每次会请求服务器提供, 给出自己的 APP ID 获取公钥.

  1. 根据官方文档, 安装必要依赖, 以及 prisma + d1 初始化的步骤: https://developers.cloudflare.com/d1/tutorials/d1-and-prisma-orm/
  2. 迁移时, 参考 prisma 文档步骤进行: https://www.prisma.io/docs/orm/overview/databases/cloudflare-d1
  3. prisma 在 cf worker 上的考虑: https://www.prisma.io/docs/orm/prisma-client/deployment/edge/deploy-to-cloudflare#general-considerations-when-deploying-to-cloudflare-workers
  4. cf 提供的 prisma 使用示例: https://developers.cloudflare.com/d1/tutorials/

标准步骤:

  1. 可选: 初始化(创建 schema, 创建首次迁移, 应用首次迁移)
  2. 修改 schema
  3. 生成迁移文件: create migrations
  4. 生成迁移文件内容: generate sql
  5. 应用迁移: apply
  6. prisma client 再生成: generate client

Authentication

起步参考

在工程中, 用到如下依赖或服务:

  1. Auth.js
  2. 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

生成 auth secret:

npx auth secret

注册和配置需要的 OAuth Provider, 比如谷歌, 苹果等.

创建登录界面, 详见: https://developers.cloudflare.com/developer-spotlight/tutorials/fullstack-authentication-with-next-js-and-cloudflare-d1/#6-build-sign-in-interface

用户注册登录所需功能

  1. 用户名密码注册, 需要邮箱验证, 需要密码hash
  2. 用户名密码登录
  3. 谷歌/苹果 OAuth 登录
  4. 忘记密码
  5. 修改密码, 需要邮箱验证
  6. 购买后自动创建用户 -> 首次登录时发送邮件链接 -> 链接中修改密码 -> 成功登录. 或在三方登录时使用相同邮箱情况下确认是 Provider 下发, 且对应应用和服务, 则可以进行创建.

数据库表设计

参考: https://vertabelo.com/blog/user-authentication-module/

External Provider 示例

  1. Hono + google auth 示例代码: https://gist.github.com/rishi-raj-jain/ef5f20f61423e7fd81d2ea32b26f56a5
  2. Hono OAuth Middleware:https://github.com/honojs/middleware/tree/main/packages/oauth-providers
  3. Lucia Auth library 学习用途, 不在生产环境下使用: https://github.com/lucia-auth/lucia

测试

d1, kv, r2 等服务在本地测试中作为外部依赖使用时(通过依赖替换模拟业务依赖), 可以参考官方仓库的写法:

D1 在测试环境中的使用

参考官方给出的测试示例.

  1. 添加 node 类型, 在 vitest.config.ts 中使用 path: bun add -d @types/node

  2. 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 },
    },
    },
    },
    },
    };
    });
  3. 下一步是两个文件的处理, 目的是让所有迁移能够在测试中被访问到:

    • 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, 因此迁移应用成功后, 也就能够正确对数据库进行处理了.

其它

  1. 关于 CI 使用 wrangler 部署: 由于没有可视化登录界面, 因此需要手动指定两个值(即 CLOUDFLARE_API_TOKENCLOUDFLARE_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",
  1. biome 配置文档: https://biomejs.dev/reference/configuration/#json
  2. biome zed 配置: https://biomejs.dev/reference/zed/#installation

具体实现记录

  1. prisma 使用 sqlite connector 时, 无法在 Schema 中定义固定长度的 String, 比如 @db.VarChar(50) 这类的如何处理?
  2. 如果在 schema 定义中无法处理, 则只能通过上层业务逻辑进行限制, 对输入严格把控