通过实际案例 Todo,学习 TypeScript,我要使用的工具及框架分别是: 运行时:Bun 后端框架:Elysia 数据库:Turso ORM:Drizzle ORM 前端:HTMX + UnoCSS 认证:JWT,可实现 Google 帐户、Github 帐户的 OAuth 2认证登陆 测试:Bun test

请为我生成一套完整的代码,为避免代码量一次性过大,可以按你的思路分成几个部分来完成

非常好的选择!这是一个现代化的技术栈,我们将分步骤实现这个 Todo 应用。我们会将项目分为以下几个部分:

  1. 项目初始化和基础设置
  2. 数据库模型和 ORM 设置
  3. 后端 API 实现
  4. 前端页面和 HTMX 交互
  5. 认证系统实现
  6. 测试

让我们从第一部分开始:

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 test

7. 错误处理和日志

为了改善我们应用的可靠性和可调试性,让我们添加一些错误处理和日志记录。

首先,安装一个日志库:

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 3000

10. 最终优化

  1. 添加缓存:考虑使用 Redis 或内存缓存来存储频繁访问的数据。

  2. 添加速率限制:使用 @elysiajs/rate-limit 插件来防止 API 滥用。

  3. 改进前端:考虑使用 Svelte 或其他轻量级框架来增强用户界面。

  4. 添加 API 文档:使用 Swagger 或类似工具生成 API 文档。

  5. 监控:集成应用程序监控工具,如 Prometheus 或 Datadog。

这就完成了我们的 Todo 应用的基本实现。现在你有了一个功能完整的全栈应用,包括认证、数据库交互、前端交互、测试和部署准备。根据你的具体需求,你可以进一步扩展和优化这个应用。