通过实际案例 Todo,学习 TypeScript,我要使用的工具及框架分别是: 运行时:Bun 后端框架:Elysia 数据库:Turso ORM:Drizzle ORM 前端:HTMX + UnoCSS 认证:JWT,可实现 Google 帐户、Github 帐户的 OAuth 2认证登陆 测试:Bun test
请为我生成一套完整的代码,为避免代码量一次性过大,可以按你的思路分成几个部分来完成
非常好的选择!这是一个现代化的技术栈,我们将分步骤实现这个 Todo 应用。我们会将项目分为以下几个部分:
- 项目初始化和基础设置
- 数据库模型和 ORM 设置
- 后端 API 实现
- 前端页面和 HTMX 交互
- 认证系统实现
- 测试
让我们从第一部分开始:
1. 项目初始化和基础设置
首先,创建一个新的项目目录并初始化:
mkdir ts-todo-app
cd ts-todo-app
bun init安装必要的依赖:
bun add elysia @elysiajs/html @elysiajs/jwt @elysiajs/cookie
bun add drizzle-orm @libsql/client
bun add -d drizzle-kit
bun add htmx.org
bun add @unocss/cli创建以下目录结构:
ts-todo-app/
├── src/
│ ├── db/
│ ├── routes/
│ ├── views/
│ └── index.ts
├── public/
├── drizzle.config.ts
├── package.json
└── tsconfig.json
更新 tsconfig.json:
{
"compilerOptions": {
"target": "esnext",
"module": "esnext",
"moduleResolution": "node",
"types": ["bun-types"],
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true
}
}创建 drizzle.config.ts:
import type { Config } from "drizzle-kit";
export default {
schema: "./src/db/schema.ts",
out: "./drizzle",
driver: 'turso',
dbCredentials: {
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_AUTH_TOKEN,
},
} satisfies Config;创建 .env 文件(确保将其添加到 .gitignore):
DATABASE_URL=libsql://your-database-url.turso.io
DATABASE_AUTH_TOKEN=your-auth-token
JWT_SECRET=your-jwt-secret
GOOGLE_CLIENT_ID=your-google-client-id
GOOGLE_CLIENT_SECRET=your-google-client-secret
GITHUB_CLIENT_ID=your-github-client-id
GITHUB_CLIENT_SECRET=your-github-client-secret
这就是项目的基本设置。在下一部分,我们将设置数据库模型和 ORM。
好的,让我们继续第二部分:数据库模型和 ORM 设置。
2. 数据库模型和 ORM 设置
首先,我们需要创建数据库模型。在 src/db/schema.ts 文件中定义我们的数据库表结构:
import { sqliteTable, text, integer } from "drizzle-orm/sqlite-core";
export const users = sqliteTable("users", {
id: text("id").primaryKey(),
email: text("email").notNull().unique(),
name: text("name"),
provider: text("provider"),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().defaultNow(),
});
export const todos = sqliteTable("todos", {
id: text("id").primaryKey(),
title: text("title").notNull(),
completed: integer("completed", { mode: "boolean" }).notNull().default(false),
userId: text("user_id").notNull().references(() => users.id),
createdAt: integer("created_at", { mode: "timestamp" }).notNull().defaultNow(),
});接下来,创建 src/db/index.ts 文件来设置数据库连接:
import { drizzle } from "drizzle-orm/libsql";
import { createClient } from "@libsql/client";
const client = createClient({
url: process.env.DATABASE_URL!,
authToken: process.env.DATABASE_AUTH_TOKEN,
});
export const db = drizzle(client);现在,我们需要创建迁移脚本。在 package.json 中添加以下脚本:
{
"scripts": {
"generate": "drizzle-kit generate:sqlite",
"migrate": "bun run src/db/migrate.ts"
}
}创建 src/db/migrate.ts 文件:
import { migrate } from "drizzle-orm/libsql/migrator";
import { db } from "./index";
async function main() {
console.log("Running migrations");
await migrate(db, { migrationsFolder: "./drizzle" });
console.log("Migrations complete");
process.exit(0);
}
main().catch((err) => {
console.error("Migration failed");
console.error(err);
process.exit(1);
});现在,我们可以生成和运行迁移:
bun run generate
bun run migrate这将创建数据库表并应用迁移。
接下来,让我们开始实现后端 API。
3. 后端 API 实现
创建 src/index.ts 文件作为我们的主应用入口:
import { Elysia } from "elysia";
import { html } from "@elysiajs/html";
import { cookie } from "@elysiajs/cookie";
import { jwt } from "@elysiajs/jwt";
import { todoRoutes } from "./routes/todos";
import { authRoutes } from "./routes/auth";
const app = new Elysia()
.use(html())
.use(cookie())
.use(jwt({
name: "jwt",
secret: process.env.JWT_SECRET!
}))
.get("/", () => "Hello, World!")
.use(todoRoutes)
.use(authRoutes)
.listen(3000);
console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`);现在,让我们创建 Todo 相关的路由。在 src/routes/todos.ts 中:
import { Elysia, t } from "elysia";
import { db } from "../db";
import { todos } from "../db/schema";
import { eq } from "drizzle-orm";
export const todoRoutes = new Elysia({ prefix: "/todos" })
.get("/", async ({ jwt, set }) => {
const payload = await jwt.verify();
if (!payload) {
set.status = 401;
return "Unauthorized";
}
return db.select().from(todos).where(eq(todos.userId, payload.sub));
})
.post("/", async ({ body, jwt, set }) => {
const payload = await jwt.verify();
if (!payload) {
set.status = 401;
return "Unauthorized";
}
const newTodo = await db.insert(todos).values({
id: crypto.randomUUID(),
title: body.title,
userId: payload.sub,
}).returning();
return newTodo[0];
}, {
body: t.Object({
title: t.String(),
})
})
.put("/:id", async ({ params, body, jwt, set }) => {
const payload = await jwt.verify();
if (!payload) {
set.status = 401;
return "Unauthorized";
}
const updatedTodo = await db.update(todos)
.set({ title: body.title, completed: body.completed })
.where(eq(todos.id, params.id))
.returning();
return updatedTodo[0];
}, {
params: t.Object({
id: t.String(),
}),
body: t.Object({
title: t.String(),
completed: t.Boolean(),
})
})
.delete("/:id", async ({ params, jwt, set }) => {
const payload = await jwt.verify();
if (!payload) {
set.status = 401;
return "Unauthorized";
}
await db.delete(todos).where(eq(todos.id, params.id));
return { success: true };
}, {
params: t.Object({
id: t.String(),
})
});这里我们实现了基本的 CRUD 操作,并添加了简单的 JWT 验证。
在下一部分,我们将实现前端页面和 HTMX 交互。
好的,让我们继续实现前端页面和 HTMX 交互。
4. 前端页面和 HTMX 交互
首先,我们需要创建一些基本的 HTML 模板。在 src/views 目录下创建以下文件:
src/views/layout.ts:
export const layout = (content: string) => `
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Todo App</title>
<script src="https://unpkg.com/htmx.org@1.9.6"></script>
<link rel="stylesheet" href="/styles.css">
</head>
<body>
${content}
</body>
</html>
`;src/views/index.ts:
import { layout } from "./layout";
export const indexPage = () => layout(`
<div class="container mx-auto p-4">
<h1 class="text-2xl font-bold mb-4">Todo App</h1>
<div id="todo-list"></div>
<form hx-post="/todos" hx-target="#todo-list" hx-swap="beforeend">
<input type="text" name="title" placeholder="New todo" class="border p-2 mr-2">
<button type="submit" class="bg-blue-500 text-white p-2 rounded">Add Todo</button>
</form>
</div>
`);src/views/todoItem.ts:
export const todoItem = (todo: { id: string, title: string, completed: boolean }) => `
<div id="todo-${todo.id}" class="flex items-center mb-2">
<input type="checkbox" ${todo.completed ? 'checked' : ''}
hx-put="/todos/${todo.id}"
hx-swap="outerHTML"
hx-target="#todo-${todo.id}"
name="completed"
class="mr-2">
<span class="${todo.completed ? 'line-through' : ''}">${todo.title}</span>
<button hx-delete="/todos/${todo.id}"
hx-target="#todo-${todo.id}"
hx-swap="outerHTML"
class="ml-2 text-red-500">Delete</button>
</div>
`;现在,让我们更新 src/routes/todos.ts 以支持 HTMX:
import { Elysia, t } from "elysia";
import { db } from "../db";
import { todos } from "../db/schema";
import { eq } from "drizzle-orm";
import { todoItem } from "../views/todoItem";
export const todoRoutes = new Elysia({ prefix: "/todos" })
.get("/", async ({ jwt, set }) => {
const payload = await jwt.verify();
if (!payload) {
set.status = 401;
return "Unauthorized";
}
const todoList = await db.select().from(todos).where(eq(todos.userId, payload.sub));
return todoList.map(todo => todoItem(todo)).join("");
})
.post("/", async ({ body, jwt, set }) => {
const payload = await jwt.verify();
if (!payload) {
set.status = 401;
return "Unauthorized";
}
const newTodo = await db.insert(todos).values({
id: crypto.randomUUID(),
title: body.title,
userId: payload.sub,
}).returning();
return todoItem(newTodo[0]);
}, {
body: t.Object({
title: t.String(),
})
})
.put("/:id", async ({ params, body, jwt, set }) => {
const payload = await jwt.verify();
if (!payload) {
set.status = 401;
return "Unauthorized";
}
const updatedTodo = await db.update(todos)
.set({ completed: body.completed })
.where(eq(todos.id, params.id))
.returning();
return todoItem(updatedTodo[0]);
}, {
params: t.Object({
id: t.String(),
}),
body: t.Object({
completed: t.Boolean(),
})
})
.delete("/:id", async ({ params, jwt, set }) => {
const payload = await jwt.verify();
if (!payload) {
set.status = 401;
return "Unauthorized";
}
await db.delete(todos).where(eq(todos.id, params.id));
return "";
}, {
params: t.Object({
id: t.String(),
})
});更新 src/index.ts 以包含主页路由:
import { Elysia } from "elysia";
import { html } from "@elysiajs/html";
import { cookie } from "@elysiajs/cookie";
import { jwt } from "@elysiajs/jwt";
import { todoRoutes } from "./routes/todos";
import { authRoutes } from "./routes/auth";
import { indexPage } from "./views/index";
const app = new Elysia()
.use(html())
.use(cookie())
.use(jwt({
name: "jwt",
secret: process.env.JWT_SECRET!
}))
.get("/", () => indexPage())
.use(todoRoutes)
.use(authRoutes)
.listen(3000);
console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`);为了添加样式,我们将使用 UnoCSS。创建 uno.config.ts 文件:
import { defineConfig } from 'unocss'
export default defineConfig({
// ...UnoCSS options
})然后在 package.json 中添加一个脚本来生成 CSS:
{
"scripts": {
"dev": "bun run --watch src/index.ts",
"build:css": "unocss 'src/**/*.ts' -o public/styles.css"
}
}运行 bun run build:css 来生成 CSS 文件。
现在,我们有了一个基本的前端界面,它可以与我们的后端 API 进行交互。下一步,我们将实现认证系统。
5. 认证系统实现
创建 src/routes/auth.ts 文件:
import { Elysia, t } from "elysia";
import { oauth2, OAuth2RequestError } from "@elysiajs/oauth";
import { db } from "../db";
import { users } from "../db/schema";
import { eq } from "drizzle-orm";
export const authRoutes = new Elysia()
.use(
oauth2({
providers: {
google: {
clientID: process.env.GOOGLE_CLIENT_ID!,
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
redirectURI: "http://localhost:3000/auth/google/callback",
},
github: {
clientID: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
redirectURI: "http://localhost:3000/auth/github/callback",
},
},
})
)
.get("/login", ({ html }) => html(`
<a href="/auth/google">Login with Google</a>
<a href="/auth/github">Login with GitHub</a>
`))
.get("/auth/:provider", ({ params: { provider } }) => {
if (provider !== "google" && provider !== "github") {
throw new Error("Invalid provider");
}
return oauth2[provider].redirect();
})
.get("/auth/:provider/callback", async ({ params: { provider }, query, set, jwt }) => {
if (provider !== "google" && provider !== "github") {
throw new Error("Invalid provider");
}
try {
const { tokenset, userinfo } = await oauth2[provider].callback(query);
let user = await db.select().from(users).where(eq(users.email, userinfo.email)).limit(1);
if (user.length === 0) {
user = await db.insert(users).values({
id: crypto.randomUUID(),
email: userinfo.email,
name: userinfo.name,
provider: provider,
}).returning();
}
const accessToken = await jwt.sign({
sub: user[0].id,
email: user[0].email,
});
set.cookie("auth", accessToken, {
httpOnly: true,
maxAge: 7 * 86400,
});
set.redirect = "/";
} catch (e) {
if (e instanceof OAuth2RequestError) {
set.status = 400;
return "Invalid code";
}
throw e;
}
})
.get("/logout", ({ set }) => {
set.cookie("auth", "", { maxAge: 0 });
set.redirect = "/login";
});更新 src/index.ts 以包含认证中间件:
import { Elysia } from "elysia";
import { html } from "@elysiajs/html";
import { cookie } from "@elysiajs/cookie";
import { jwt } from "@elysiajs/jwt";
import { todoRoutes } from "./routes/todos";
import { authRoutes } from "./routes/auth";
import { indexPage } from "./views/index";
const app = new Elysia()
.use(html())
.use(cookie())
.use(jwt({
name: "jwt",
secret: process.env.JWT_SECRET!
}))
.use(authRoutes)
.get("/", async ({ cookie, jwt, set }) => {
const token = cookie.auth;
if (!token) {
set.redirect = "/login";
return;
}
const payload = await jwt.verify(token);
if (!payload) {
set.redirect = "/login";
return;
}
return indexPage();
})
.use(todoRoutes)
.listen(3000);
console.log(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`);这 completes 了认证系统的实现。在下一部分,我们将编写一些测试。
好的,让我们继续完善我们的应用,添加一些测试,并进行最后的优化和部署准备。
6. 编写测试
为了确保我们的应用正常工作,我们应该添加一些测试。我们将使用 Bun 的内置测试运行器。
首先,在 package.json 中添加一个测试脚本:
{
"scripts": {
"test": "bun test"
}
}然后,创建一个 tests 目录,并添加以下测试文件:
tests/todos.test.ts:
import { describe, expect, it, mock } from "bun:test";
import { Elysia } from "elysia";
import { todoRoutes } from "../src/routes/todos";
describe("Todo Routes", () => {
const app = new Elysia().use(todoRoutes);
it("should get todos", async () => {
const jwt = { verify: mock(() => Promise.resolve({ sub: "user-id" })) };
const response = await app.handle(
new Request("http://localhost/todos", {
method: "GET",
}),
{ jwt }
);
expect(response.status).toBe(200);
});
it("should create a new todo", async () => {
const jwt = { verify: mock(() => Promise.resolve({ sub: "user-id" })) };
const response = await app.handle(
new Request("http://localhost/todos", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: "New Todo" }),
}),
{ jwt }
);
expect(response.status).toBe(200);
const body = await response.text();
expect(body).toContain("New Todo");
});
});tests/auth.test.ts:
import { describe, expect, it, mock } from "bun:test";
import { Elysia } from "elysia";
import { authRoutes } from "../src/routes/auth";
describe("Auth Routes", () => {
const app = new Elysia().use(authRoutes);
it("should render login page", async () => {
const response = await app.handle(
new Request("http://localhost/login", {
method: "GET",
})
);
expect(response.status).toBe(200);
const body = await response.text();
expect(body).toContain("Login with Google");
expect(body).toContain("Login with GitHub");
});
// Add more tests for OAuth flows if needed
});运行测试:
bun run test7. 错误处理和日志
为了改善我们应用的可靠性和可调试性,让我们添加一些错误处理和日志记录。
首先,安装一个日志库:
bun add pino pino-pretty然后,创建一个 src/utils/logger.ts 文件:
import pino from "pino";
export const logger = pino({
transport: {
target: "pino-pretty",
options: {
colorize: true,
},
},
});更新 src/index.ts 以包含错误处理和日志记录:
import { Elysia } from "elysia";
import { html } from "@elysiajs/html";
import { cookie } from "@elysiajs/cookie";
import { jwt } from "@elysiajs/jwt";
import { todoRoutes } from "./routes/todos";
import { authRoutes } from "./routes/auth";
import { indexPage } from "./views/index";
import { logger } from "./utils/logger";
const app = new Elysia()
.use(html())
.use(cookie())
.use(jwt({
name: "jwt",
secret: process.env.JWT_SECRET!
}))
.use(authRoutes)
.get("/", async ({ cookie, jwt, set }) => {
const token = cookie.auth;
if (!token) {
set.redirect = "/login";
return;
}
const payload = await jwt.verify(token);
if (!payload) {
set.redirect = "/login";
return;
}
return indexPage();
})
.use(todoRoutes)
.onError(({ code, error, set }) => {
logger.error(error);
set.status = code === "NOT_FOUND" ? 404 : 500;
return "An error occurred";
})
.listen(3000);
logger.info(`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`);8. 环境变量和配置
创建一个 .env.example 文件来列出所需的环境变量:
DATABASE_URL=
DATABASE_AUTH_TOKEN=
JWT_SECRET=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
GITHUB_CLIENT_ID=
GITHUB_CLIENT_SECRET=
确保在 .gitignore 文件中包含 .env:
node_modules
.env
9. 部署准备
为了准备部署,我们需要添加一个生产构建脚本。更新 package.json:
{
"scripts": {
"dev": "bun run --watch src/index.ts",
"build": "bun build src/index.ts --outdir dist",
"start": "bun run dist/index.js",
"build:css": "unocss 'src/**/*.ts' -o public/styles.css"
}
}创建一个 Dockerfile:
FROM oven/bun
WORKDIR /app
COPY package.json bun.lockb ./
RUN bun install --production
COPY . .
RUN bun run build
RUN bun run build:css
ENV NODE_ENV production
CMD ["bun", "start"]
EXPOSE 300010. 最终优化
-
添加缓存:考虑使用 Redis 或内存缓存来存储频繁访问的数据。
-
添加速率限制:使用
@elysiajs/rate-limit插件来防止 API 滥用。 -
改进前端:考虑使用 Svelte 或其他轻量级框架来增强用户界面。
-
添加 API 文档:使用 Swagger 或类似工具生成 API 文档。
-
监控:集成应用程序监控工具,如 Prometheus 或 Datadog。
这就完成了我们的 Todo 应用的基本实现。现在你有了一个功能完整的全栈应用,包括认证、数据库交互、前端交互、测试和部署准备。根据你的具体需求,你可以进一步扩展和优化这个应用。