我是一个 Next.js 的爱好者,我的很多项目包括本博客都是基于 Next.js 开发的。在开发 Next.js 过程中我觉得主要有两点让我不满意:
框架未知论:在使用 Next.js 过程中我经常会纠结
use client和use server的运行模型,小心谨慎的处理着他们的区别。另一点就是缓存,如果你用过就知道其实他的执行模型是很不清晰的,要自己做一些试验才能 100% 确定其作用。
基于这些原因我决定研究更轻量级的框架,它就是 Tanstack Start。它是基于 @tanstack/react-router 和 Vite 之上的全栈框架,他轻量、执行模型明确、可以部署在任何云服务平台上。
由此我选择的核心基础框架和方案就是:
Tanstack Start: 用于编写前端和后端的代码
Drzzle: 用于定义数据库的 Schema 和 ORM 的数据库操作框架
Cloudflare Worker: 用于部署主应用和执行一些 Queue 任务
Cloudflare D1: 用于存储应用的数据库
Cloudflare Cron: 用于执行程序中的定时任务
Cloudflare Queue: 用于执行一些队列任务
下面我就一步步从 0 到 1 来分享一下我是怎么实现的,其中包括本地开发环境,Pre 环境 和 Prod 环境的部署上线
目标受众
由于这不是一篇讲解全栈开发的教程,所以本博重点在于如何实现本地开发和部署到 Cloudflare,所以这边博客是为有一定经验的朋友准备的。如果你完全不具备全栈开发经验也可以跟着一步一步操作,这也是一个学习过程。
开发前的准备
在开发前你需要准备一个 Cloudflare 账号备用,后期需要部署时的授权。
我还需要介绍一下 Better-T-Stack 它是一个用于创建项目基础结构的一个 CLI 工具,可以为我们提供一个良好的目录结构。接下来我们就使用它来初始化一个项目吧。
初始化项目
首先运行命令 pnpm create better-t-stack@latest 初始化一个项目,接下来有一个选项。我的选择是:
◇ Enter your project name or path (relative to current directory)
│ happy-tanstack-cf
│
◇ Select project type
│ Web
│
◇ Choose web
│ TanStack Start
│
◇ Select backend
│ Self (Fullstack)
│
◇ Select database
│ SQLite
│
◇ Select ORM
│ Drizzle
│
◇ Select API type
│ None
│
◇ Select authentication provider
│ Better-Auth
│
◇ Select payments provider
│ None
│
◇ Select addons
│ Biome, Husky
│
◇ Include examples
│ none
│
◇ Select sqlite setup option
│ Cloudflare D1
│
◇ Initialize git repository?
│ No
│
◇ Choose package manager
│ pnpm
│
◇ Install dependencies?
│ No
当上面代码执行完毕以后,你会得到这样一个目录结构:
.
├── apps
│ └── web # 应用主程序
│ ├── public # 公共资源
│ └── src
│ ├── components
│ ├── functions # API 函数
│ ├── lib
│ ├── middleware # 中间件
│ └── routes # 页面和 API 路由
│ └── api
└── packages
├── auth # Better Auth 登录模块
│ └── src
├── config # 项目基础配置
├── db # 数据库模块
│ └── src
│ └── schema
├── env # 环境变量模块
│ └── src
├── infra # 基础设置模块
└── ui # 基于 Shadcn 组件的 UI 模块
└── src
├── components
├── hooks
├── lib
└── styles
这里值得一提的是 infra 模块,它是一个基于 Alchemy 框架的将基础设施抽象成代码的框架。所有的 Cloudflare 资源创建和释放都将是通过代码自动化来完成,期间不需要我们手动操作。
运行本地开发环境
现在运行命令 pnpm i 去安装我们的项目依赖文件。
执行 pnpm dev 启动本地开发环境,浏览器访问 http://localhost:3001 即可看到界面。
pnpm 追加日志
我开发中一般会运行 pnpm -reporter=append-only dev 让 CLI
的日志不折叠这样方便查看日志
看到这样的日志就证明我们的服务启动成功:
packages/infra dev: VITE v8.0.11 ready in 3554 ms
packages/infra dev: ➜ Local: http://localhost:3001/
packages/infra dev: ➜ Network: use --host to expose
packages/infra dev: ➜ Debug: http://localhost:3001/__debug
packages/infra dev: [updating] web Updating Resource...
packages/infra dev: [updated] web Updated Resource
packages/infra dev: Web -> http://localhost:3001/
Alchemy 模拟环境
启动服务后我们可以在根目录看到 .alchemy 文件夹,这是 Alchemy
为我们的模拟环境,它包含了我们的基础设施资源。
.alchemy
└── miniflare
└── v3
├── cache
│ └── miniflare-CacheObject
├── d1
│ └── miniflare-D1DatabaseObject
├── do
└── workflows
它主要是会为我们模拟一个本地的 SQLite 数据库。
数据库
因为我们要部署到 Cloudflare D1 所以我这里选择了 SQLite 数据库。因为我们选择了 Better-Auth 作为登录验证,所以我们可以查看 packages/db/src/schema/auth.ts 文件这就是 Betteru-Auth 的基础 Schema 文件。通过这个文件我们就可以帮我们的数据库表和字段迁移到 SQLite 数据库中。
现在我们执行 pnpm run db:generate 就可以自动生成数据库表和字段。
随便找一个数据库管理软件,我使用的是 SQLiteStudio 打开 .alchemy/miniflare/v3/d1/miniflare-D1DatabaseObject/3d4ffb7927e8eeadb0c3bb4dfa75c351d1eab010615de505483c732d2b67801c.sqlite,你的数据库名字可能会不一样但是一定在 miniflare-D1DatabaseObject 目录下。就可以查看到我们已经迁移好的表和数据库了。

如果你需要新增或修改 Schema 则可以在 packages/db/src/schema 中新增文件,像 auth.ts 一样,然后再次执行 pnpm run db:generate 就可以自动生成数据库表和字段。
测试程序正确性
现在我们访问 http://localhost:3001/login 来注册一个账号验证程序是否正确。当你看到如下界面的时候代表基础程序一切正常运行。

部署到 Cloudflare
当程序运行正常后需要尽快部署到 Cloudflare 上,因为部署的越晚程序越部署调试的难度会加大。
授权 Cloudflare
我们使用了 alchemy 框架所以我们需要先执行 pnpm alchemy configure 然后来授权 Cloudflare 的访问权限。
接下来会让你输入一个 Profile name,我们保持默认的 defualt 就可以了,按回车就会跳转授权。
你只需要登录 Cloudflare 跟着操作就可以了。
确定部署环境
目前我想部署两个环境分别是: Pre 和 Prod,Pre 是预发布环境用来验证程序的正确性最后再发布到 Prod 环境上。
下面就来配置环境,Prod 环境和 Pre 环境其实是一样的配置过程。
创建 env 文件
创建 packages/infra/.env.pre 和 packages/infra/.env.prod 文件内容暂时为空,请注意所有 .env 文件都不要提交到 Git 仓库中。
然后我们将 apps/web/.env 中的内容放到 packages/infra/.env.pre 和 packages/infra/.env.prod 文件中。
然后将 BETTER_AUTH_URL 和 CORS_ORIGIN 修改为实际网址,如果你没有线上的域名那么可以使用 worker.dev 的默认域名。
比如你 Cloudflare 登录的邮箱为: [email protected],那么为你生成的 URL 就是:https://happy-tanstack-cf-web-pre.abc-net.workers.dev
最终配置就是这样的:
# pre 配置
BETTER_AUTH_SECRET=4MrV6HA2C5vHmVYAhMZpsCunVTllC8aT
BETTER_AUTH_URL=https://happy-tanstack-cf-web-pre.abc-net.workers.dev
CORS_ORIGIN=https://happy-tanstack-cf-web-pre.abc-net.workers.dev
# prod 配置
BETTER_AUTH_SECRET=4MrV6HA2C5vHmVYAhMZpsCunVTllC8aT
BETTER_AUTH_URL=https://happy-tanstack-cf-web-prod.abc-net.workers.dev
CORS_ORIGIN=https://happy-tanstack-cf-web-prod.abc-net.workers.dev
创建部署命令
首先在根目录中的 package.json 新增如下两个命令,分别对应 Pre 和 Prod 的发布命令
{
"scripts": {
"release:pre": "pnpm --filter @happy-tanstack-cf/infra release:pre",
"release:prod": "pnpm --filter @happy-tanstack-cf/infra release:prod"
}
}
然后在 packages/infra/package.json 中新增如下两个命令:
{
"scripts": {
"release:pre": "alchemy deploy --stage pre --env-file .env.pre",
"release:prod": "alchemy deploy --stage prod --env-file .env.prod"
}
}
alchemy deploy 会在 Cloudflare 创建 Worker、D1、编译打包代码、上传到 Cloudflare 上。
开始部署
完成以上步骤后,执行 pnpm release:pre 就可以开始部署 Pre 环境了。
等待一会看到如下界面代表部署成功,可以访问这个地址并且验证功能,如果有错误请根据上面步骤仔细检查。

测试注册是否正确

发了了什么
我们一步步操作服务就这样在开发环境和 Pre 环境运行起来了。但是这背后到底发生了什么?
答案就是在 apps/web/dist/server/wrangler.json 文件中,它是 Cloudflare 的 Wrangler 配置文件,在这个配置文件中我们可以定义需要在 Cloudflare 上部创建的资源然后绑定到我们项目。同时这些资源会注入到 Worker 中的 Env 中,这样我们就可以在代码中调用对应的服务。
比如我们要调用数据库操作,我们可以查看 packages/db/src/index.ts 文件,代码是这样的:
import { env } from "@happy-tanstack-cf/env/server";
import { drizzle } from "drizzle-orm/d1";
import * as schema from "./schema";
export function createDb() {
return drizzle(env.DB, { schema });
}
这里 env.DB 中的 DB 就是来之 wrangler.json 文件中的绑定资源名称。查看 wrangler.json 文件可以验证这一点:
下面 d1_databases.binding 就是要注入到 Env 中名称,你也可以改成别的名称。
{
"topLevelName": "happy-tanstack-cf-web-pre",
"vars": {
"CORS_ORIGIN": "https://happy-tanstack-cf-web-pre.abc-net.workers.dev",
"BETTER_AUTH_SECRET": "4MrV6HA2C5vHmVYAhMZpsCunVTllC8aT",
"BETTER_AUTH_URL": "https://happy-tanstack-cf-web-pre.abc-net.workers.dev"
},
"r2_buckets": [],
"d1_databases": [
{
"binding": "DB",
"database_id": "775286b6-eb64-40c0-8784-6bd1bd1086fa",
"database_name": "happy-tanstack-cf-database-pre",
"migrations_dir": "../../packages/db/src/migrations",
"preview_database_id": "775286b6-eb64-40c0-8784-6bd1bd1086fa"
}
]
}
但是上面的 wrangler.json 文件只是现象,到底是什么会导致生成 d1_databases 这样的配置呢?
答案就在 packages/infra/alchemy.run.ts 文件中的 D1Database 对象的创建:
import alchemy from "alchemy";
import { D1Database, TanStackStart } from "alchemy/cloudflare";
import { config } from "dotenv";
config({ path: "./.env" });
config({ path: "../../apps/web/.env" });
const app = await alchemy("happy-tanstack-cf");
// 数据库资源创建
const db = await D1Database("database", {
migrationsDir: "../../packages/db/src/migrations",
});
export const web = await TanStackStart("web", {
cwd: "../../apps/web",
bindings: {
DB: db,
CORS_ORIGIN: alchemy.env.CORS_ORIGIN!,
BETTER_AUTH_SECRET: alchemy.secret.env.BETTER_AUTH_SECRET!,
BETTER_AUTH_URL: alchemy.env.BETTER_AUTH_URL!,
},
});
console.log(`Web -> ${web.url}`);
await app.finalize();
我们可以看到 alchemy.run.ts 文件中有这样一段代码:
// 创建资源
const db = await D1Database("database", {
migrationsDir: "../../packages/db/src/migrations",
});
export const web = await TanStackStart("web", {
cwd: "../../apps/web",
bindings: {
DB: db, // 绑定到 Env 中的名称
CORS_ORIGIN: alchemy.env.CORS_ORIGIN!,
BETTER_AUTH_SECRET: alchemy.secret.env.BETTER_AUTH_SECRET!,
BETTER_AUTH_URL: alchemy.env.BETTER_AUTH_URL!,
},
});
它首先创建了一个数据库资源,然后将其绑定到 bindings 中,在 bindings 中我们可以定义我们需要注入到 Env 中的名称。
这样在你编译的时候就是生成对应的 wrangler.json 配置文件然后发布到 Cloudflare 上,一切就是这样工作起来的。
环境变量
环境变量有两种类型一种为保密环境变量,另一种为公开环境变量。
export const web = await TanStackStart("web", {
cwd: "../../apps/web",
bindings: {
DB: db, // 绑定到 Env 中的名称
CORS_ORIGIN: alchemy.env.CORS_ORIGIN!,
BETTER_AUTH_SECRET: alchemy.secret.env.BETTER_AUTH_SECRET!,
BETTER_AUTH_URL: alchemy.env.BETTER_AUTH_URL!,
},
});
上面我们使用了 alchemy.env.CORS_ORIGIN 公开环境变量,它会在 Cloudflare 中以明文显示。
alchemy.secret.env.BETTER_AUTH_SECRET 这种为非公开环境变量,它会在 Cloudflare 中以加密显示,部署以后在 Cloudflare 我们无法看到其真实的值。
你可以根据你的需要设置不同的环境变量类型
增加 Cloudflare Cron 定时任务
好了,现在大概理清了一些基本的运行原理,现在我们继续为项目增加一个定时任务。定时任务是一种在程序中非常常见的功能,比如每天早上十点后发送邮件。
下面我们就来创建一个定时任务,它会在每天早上十点后发送一封邮件。
为了实现定时任务,首先我们要定义定时任务执行的时间,在 packages/infra/alchemy.run.ts 中定义定时任务 Cron 表达:
// 省略上面的代码
export const web = await TanStackStart("web", {
cwd: "../../apps/web",
bindings: {
DB: db,
CORS_ORIGIN: alchemy.env.CORS_ORIGIN!,
BETTER_AUTH_SECRET: alchemy.secret.env.BETTER_AUTH_SECRET!,
BETTER_AUTH_URL: alchemy.env.BETTER_AUTH_URL!,
},
// 这里定义定时任务执行时间
crons: ["* * * * *"], //["0 10 * * *"],
});
// 省略下面的代码
定义完毕定时任务后,我们需要定义定时任务接收的代码。根据 wrangler 官方文档可以看到需要在 server.ts 中定义一个名为 scheduled 的函数。一旦有定时任务达到会自动执行。
我们添 apps/web/src/server.ts 文件定义如下代码:
import handler from "@tanstack/react-start/server-entry";
import { sendMail } from "./functions/send-mail";
export default {
fetch(request: Request) {
return handler.fetch(request);
},
async scheduled(
event: ScheduledEvent,
env: Env,
_ctx: ExecutionContext
): Promise<void> {
if (event.cron === "0 10 * * *") {
console.log("开始发送电子邮件");
await sendMail();
}
},
};
这里我解释一下 fetch 函数是程序的主入口代码,可以定义一些中间件和国际化函数处理,其他方法可以参考 wrangler 文档。
然后我们实现这个模拟邮件发送 sendMail 方法:
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
export async function sendMail() {
// TODO: 模拟发送电子邮件
await sleep(1000);
console.log("电子邮件已发送");
}
再次启动服务等待结果定时任务并没有触发,这是因为在开发环境定时任务不会自动触发需要手动执行:
http://localhost:3001/cdn-cgi/handler/scheduled?cron=0+10+*+*+*
才会在开发环境触发定时任务。
如果是部署到 Cloudflare 就会自动触发。
真实的邮件发送
要使用 Cloudflare 发送电子邮件,首先需要按照 文档,初始化服务。
然后根据 Alchemy文档 我们需要配置 EmailSender 和绑定到 Env 中。
首先我们再次进入 packages/infra/alchemy.run.ts 文件,新建一个 EmailSender 对象:
// 发送邮件配置
const emailSendConfig = EmailSender({
allowedSenderAddresses: ["[email protected]"],
// dev: { remote: true }, // 如果你想在开发环境发送真实的邮件请打开它
});
然后我们只需要将 emailSendConfig 对象绑定到 Env 中即可:
export const web = await TanStackStart("web", {
bindings: {
EMAIL: emailSendConfig,
// 省略其他代码
},
});
现在我们来将之前使用 sendMail 方法的代码替换成如下代码:
import { env } from "@happy-tanstack-cf/env/server";
export async function sendMail(to: string) {
await env.EMAIL.send({
to,
from: "[email protected]",
subject: "测试邮件abc",
html: "<h1>测试邮件abc</h1>",
});
}
然后更新定时任务重的函数调用,传递一个 email 的接收地址:
export default {
async scheduled(
event: ScheduledEvent,
env: Env,
_ctx: ExecutionContext
): Promise<void> {
if (event.cron === "0 10 * * *") {
console.log("开始发送电子邮件");
await sendMail("[email protected]"); // 传递一个接收邮件地址
}
},
};
现在我们手动运行定时任务 http://localhost:3001/cdn-cgi/handler/scheduled?cron=0+10+*+*+* 去触发邮件的发送,查看控制台日志有如下输出:
packages/infra dev: 开始发送电子邮件
packages/infra dev: send_email binding called with MessageBuilder:
packages/infra dev: From: [email protected]
packages/infra dev: To: [email protected]
packages/infra dev: Subject: 测试邮件abc
packages/infra dev: HTML: /var/folders/kr/mdb4d54j1m176s8c2z1t70y80000gn/T/miniflare-75e51ec333560116240eda8a14429b8e/email/email-html/52d0ff65-0694-47d3-9f8b-f5e2722a5285.html
上面的 HTML 地址就是我们邮件发送的 HTML,你可以用浏览器打开查看是否正确。
找不到 EmailSender 导出成员错误
如果你的代码提示模块“"alchemy/cloudflare"”没有导出的成员“EmailSender”,请更新
pnpm-workspace.yaml 中的 alchemy 版本到 ^0.93.7, 重新执行 pnpm i 即可。
Queue 队列任务
现在我们来实现队列任务,由于队列需要有单独的名称,首先我们要区分 Pre 和 Prod 环境的队列名称。
现在我们需在 alchemy.run.ts 文件定义定义一个 stage 变量用于获取我们编译时传递的环境。
const stage = process.env.STAGE ?? "dev";
然后需要创建一个 队列名称 + stage 的队列名称:
const queueName = `example-queue-${stage}`;
接下来需要创建消息队列对象:
// 处理消息队列配置;
const exampleQueue = await Queue(queueName, {
name: queueName,
});
最后将名称和消息队列绑定到 TanStackStart 对象上,完整的代码如下:
import alchemy from "alchemy";
import {
D1Database,
EmailSender,
Queue,
TanStackStart,
} from "alchemy/cloudflare";
import { config } from "dotenv";
// 省略代码
const stage = process.env.STAGE ?? "dev";
const queueName = `example-queue-${stage}`;
// 处理消息队列配置;
const exampleQueue = await Queue(queueName, {
name: queueName,
});
export const web = await TanStackStart("web", {
// 省略的代码
bindings: {
EXAMPLE_QUEUE: exampleQueue,
},
eventSources: [
{
name: queueName,
queue: exampleQueue,
settings: {
batchSize: 4,
maxConcurrency: 3,
maxRetries: 5,
maxWaitTimeMs: 2000,
retryDelay: 30,
},
},
],
});
console.log(`Web -> ${web.url}`);
await app.finalize();
队列任务定义完毕,接下来我们需要在 apps/web/src/server.ts 中定义队列处理函数,代码如下:
import handler from "@tanstack/react-start/server-entry";
import { sendMail } from "./functions/send-mail";
export default {
// 省略代码
async queue(batch: MessageBatch<{ id: string }>, _env: Env): Promise<void> {
batch.messages.map(async (msg) => {
console.log("执行一些异步处理任务", msg.body.id);
msg.ack(); // 告诉队列执行完成
});
},
};
好了,队列处理任务模拟完成。现在我们在定义任务 Cron 中去创建队列任务:
import handler from "@tanstack/react-start/server-entry";
import { sendMail } from "./functions/send-mail";
export default {
fetch(request: Request) {
return handler.fetch(request);
},
async scheduled(
event: ScheduledEvent,
env: Env,
_ctx: ExecutionContext
): Promise<void> {
console.log("event", event.cron);
if (event.cron === "0 10 * * *") {
console.log("开始发送电子邮件");
await sendMail("[email protected]");
} else if (event.cron === "0 20 * * *") {
// 创建队列任务
await env.EXAMPLE_QUEUE.send({ id: 123 });
}
},
async queue(batch: MessageBatch<{ id: string }>, _env: Env): Promise<void> {
batch.messages.map(async (msg) => {
console.log("执行一些异步处理任务", msg.body.id);
msg.ack(); // 告诉队列执行完成
});
},
};
现在我们在浏览器中访问:http://localhost:3001/cdn-cgi/handler/scheduled?cron=0+20+*+*+* 去触发我们定时任务创建一个队列任务
然后查看控制台日志,可以看到创建了一个队列任务,并且执行了一个异步任务。
│ [vite] hot updated: virtual:cloudflare/worker-entry
│ event 0 20 * * *
│ 执行一些异步处理任务 undefined
│ QUEUE example-queue-dev 1/1 (4ms)
好了队列任务的创建和执行已经介绍完毕,如果你想了解更对关于队列任务的参数定义和文档,请访问这个地址
部署状态持久化
由于之前我们部署过,现在你可以查看 apps/web/dist/server/wrangler.json 文件,可以看到其中有:
{
"d1_databases": [
{
"binding": "DB",
"database_id": "64a40142-f6b8-4ba8-9dad-fa9b00aa4f4e",
"database_name": "happy-tanstack-cf-database-pre",
"migrations_dir": "../../packages/db/src/migrations",
"preview_database_id": "64a40142-f6b8-4ba8-9dad-fa9b00aa4f4e"
}
]
}
这里的 d1_databases 就是我们刚刚定义的数据库资源,它会自动在部署到 Cloudflare 上的时候被创建。由于这些数据我们是不会提交到 Git,所有一旦我们更换了电脑那么重新部署将会重新创建这些数据库。但是 Cloudflare 上又存在同名的数据库这个时候部署就会失败。
这个的解决方案就是使用 CloudflareStateStore 将我们的部署配置存储到 Cloudflare Durable Object 上去。这样即便我们更换电脑,配置文件也会在云上就可以找到我们的数据库资源。
配置方法如下:
import alchemy from "alchemy";
import {
D1Database,
EmailSender,
Queue,
TanStackStart,
} from "alchemy/cloudflare";
import { CloudflareStateStore } from "alchemy/state";
import { config } from "dotenv";
config({ path: "./.env" });
config({ path: "../../apps/web/.env" });
const stage = process.env.STAGE ?? "dev";
// alchemy 新增一个参数,传入我们的 Store 配置对象
const app = await alchemy("happy-tanstack-cf", {
stage,
stateStore:
stage !== "dev"
? (scope) =>
new CloudflareStateStore(scope, {
scriptName: `launly-app-state-${stage}`,
})
: undefined,
});
const queueName = `example-queue-${stage}`;
上面我又做了一下环境区分,如果是开发环境使用本地生成的配置,只有是 Pre 或 Prod 才存储配置到 Durable Object 上去。这样即便我们更换电脑,配置文件也会在云上就可以找到我们的数据库资源。
现在运行命令 pnpm release:prod 就可以开始部署了,如果不刚好换了电脑又没有这些配置文件。最简单的办法就是把 Cloudflare 上的 Worker 和 D1 删了重新部署一次即可。下一次部署就没问题了。
总结
我们使用了 Tansack Start 和 Vite 框架作为我们的业务层,然后使用 Alchemy 将我们的 Cron、Email、Queue 等项目需要的功能抽象成了代码对象去自动管理资源。这样我们就可以更多的关心业务层面而不必关心基础设置,专心完成业务逻辑提供更好的软件功能。
最后附上 Github 地址以便参考。