Files
zhuangang/topics/backend/nodejs/examples/express-bff/app.ts
T

89 lines
3.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 不自动运行:需要先 npm install(express 5)。Node 23.6+ 可以直接运行 .ts(类型擦除)
// Express 5 BFF 示例:请求 id、输入校验、统一错误处理、async 路由(Express 5 会自动捕获 Promise 拒绝)、SSE 流式转发
import express, { type NextFunction, type Request, type Response } from "express";
import { randomUUID } from "node:crypto";
export class HttpError extends Error {
status: number;
code: string;
constructor(status: number, code: string, message: string) {
super(message);
this.status = status;
this.code = code;
}
}
export type Deps = {
// 上游 AI 服务:返回逐段文本的异步迭代器;真实项目里是对 Python 服务的 fetch 流式调用
askLLM: (question: string, signal: AbortSignal) => AsyncIterable<string>;
findOrder: (id: string) => Promise<{ id: string; status: string } | null>;
};
export function createApp(deps: Deps) {
const app = express();
app.use(express.json({ limit: "100kb" })); // 限制请求体大小
app.use((req, res, next) => { // 请求 id:贯穿日志和上游调用,便于排查
const id = req.get("x-request-id") ?? randomUUID();
res.locals.requestId = id;
res.set("x-request-id", id);
next();
});
app.get("/healthz", (_req, res) => {
res.json({ ok: true });
});
// Express 5:async 处理函数抛出的错误会自动交给错误处理中间件(Express 4 需要手动 next(err))
app.get("/orders/:id", async (req, res) => {
const id = req.params.id;
if (!/^[A-Z]\d{3,}$/.test(id)) throw new HttpError(400, "BAD_ORDER_ID", "订单号格式不正确");
const order = await deps.findOrder(id);
if (!order) throw new HttpError(404, "ORDER_NOT_FOUND", "订单不存在");
res.json(order);
});
app.post("/chat", async (req, res) => {
const question: unknown = req.body?.question;
if (typeof question !== "string" || question.length === 0 || question.length > 500) {
throw new HttpError(400, "BAD_QUESTION", "question 必须是 1~500 字的字符串");
}
const controller = new AbortController();
res.on("close", () => controller.abort()); // 客户端断开:通知上游停止生成
res.set({ "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "X-Accel-Buffering": "no" });
res.flushHeaders();
try {
for await (const token of deps.askLLM(question, controller.signal)) {
if (controller.signal.aborted) break;
res.write(`data: ${JSON.stringify({ token })}\n\n`);
}
res.write("event: done\ndata: {}\n\n");
} catch (e) {
// 响应头已经发出,不能再改状态码:用一个 error 事件告诉前端
res.write(`event: error\ndata: ${JSON.stringify({ message: "生成失败,请重试" })}\n\n`);
console.error(res.locals.requestId, e);
} finally {
res.end();
}
});
app.use((_req, _res, next) => next(new HttpError(404, "NOT_FOUND", "接口不存在")));
// 统一错误处理:4 个参数。不把内部错误细节返回给客户端
app.use((err: unknown, _req: Request, res: Response, _next: NextFunction) => {
if (err instanceof HttpError) {
res.status(err.status).json({ code: err.code, message: err.message, requestId: res.locals.requestId });
return;
}
const status = (err as { status?: number }).status;
if (status === 400 || status === 413) { // express.json 的解析错误 / 请求体过大
res.status(status).json({ code: "BAD_REQUEST", message: "请求体无效", requestId: res.locals.requestId });
return;
}
console.error(res.locals.requestId, err);
res.status(500).json({ code: "INTERNAL", message: "服务器内部错误", requestId: res.locals.requestId });
});
return app;
}