M0:照壁 BFF 骨架(Fastify 透明代理:鉴权/幂等/续传头透传、SSE 直通、HJ-3001 映射)及测试与镜像
This commit is contained in:
1 parent
f3058a64c3
commit
8d8d81e757
10 files changed
+2700
No files matched your search
@@ -0,0 +1,32 @@
|
||||
# 照壁 · 魂镜 BFF(hunjing-bff)
|
||||
|
||||
> 文化命名「照壁」(契约词典 §2.1):入门第一重——鉴权透传、SSE/WebSocket 代理、响应透传与类型桥接。照壁无状态、无业务规则、无权威数据;"能否看到"的语义一律来自灵台(service-boundaries.md §3)。
|
||||
|
||||
## 职责(rewrite/1.0 M0 骨架)
|
||||
|
||||
- `ALL /api/*` → 灵台透明代理:透传 `Authorization` / `Idempotency-Key` / `Last-Event-ID` / `X-Trace-Id`;
|
||||
- SSE 流式直通:检测 `text/event-stream` 后 hijack 连接,逐块转发并设置 `X-Accel-Buffering: no`;
|
||||
- 请求体按原始字节透传(任意 Content-Type,含 multipart);
|
||||
- 上游不可达映射 `HJ-3001`(API 契约 §5 错误结构);
|
||||
- `GET /healthz` 本地健康检查。
|
||||
|
||||
## 运行
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # 开发(tsx watch)
|
||||
BACKEND_URL=http://127.0.0.1:8000 BFF_PORT=3000 npm start
|
||||
|
||||
npm test # vitest
|
||||
npm run type-check
|
||||
```
|
||||
|
||||
环境变量:`BFF_PORT`(默认 3000)、`BACKEND_URL`(默认 http://127.0.0.1:8000)、`BFF_BODY_LIMIT`、`BFF_LOG_LEVEL`。
|
||||
|
||||
## 约束
|
||||
|
||||
- 代理超时须大于灵台同类超时,避免照壁先掐断 SSE 流;
|
||||
- 不得在此层添加权限判断或业务裁剪逻辑(响应字段白名单按 rewrite-plan M2+ 需要再引入);
|
||||
- 日志不落请求体敏感原文。
|
||||
|
||||
设计依据:[照壁 BFF 与镜匣桌面壳](../docs/architecture/bff-and-desktop.md)。
|
||||
Generated
+2336
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "hunjing-bff",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "照壁 · 魂镜 BFF(鉴权透传 / SSE 代理 / 响应透传)",
|
||||
"type": "module",
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
},
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/server.js",
|
||||
"test": "vitest run",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"fastify": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"tsx": "^4.19.0",
|
||||
"typescript": "^5.7.0",
|
||||
"vitest": "^3.0.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
/** 照壁配置:环境变量读取(开发档默认值对齐 docker-compose)。 */
|
||||
|
||||
export interface BffConfig {
|
||||
port: number;
|
||||
backendUrl: string;
|
||||
/** 请求体上限(字节);SSE 响应流不受此限。 */
|
||||
bodyLimit: number;
|
||||
logLevel: string;
|
||||
}
|
||||
|
||||
export function loadConfig(env: NodeJS.ProcessEnv = process.env): BffConfig {
|
||||
return {
|
||||
port: Number(env.BFF_PORT ?? 3000),
|
||||
backendUrl: (env.BACKEND_URL ?? "http://127.0.0.1:8000").replace(/\/$/, ""),
|
||||
bodyLimit: Number(env.BFF_BODY_LIMIT ?? 4 * 1024 * 1024),
|
||||
logLevel: env.BFF_LOG_LEVEL ?? "info",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/** 代理核心:鉴权透传、请求转发、SSE 流式透传与错误映射。
|
||||
|
||||
* 照壁不拥有业务规则(service-boundaries.md §3):能否看到的语义一律来自灵台;
|
||||
* 这里只做传输与形状处理——透传凭证/幂等/续传头,流式响应禁缓冲直通。
|
||||
*/
|
||||
import { Readable } from "node:stream";
|
||||
import type { FastifyReply, FastifyRequest } from "fastify";
|
||||
|
||||
/** 需要原样透传的请求头(小写)。 */
|
||||
const FORWARD_REQUEST_HEADERS = [
|
||||
"authorization",
|
||||
"content-type",
|
||||
"accept",
|
||||
"idempotency-key",
|
||||
"last-event-id",
|
||||
"x-trace-id",
|
||||
] as const;
|
||||
|
||||
/** 需要透传的响应头(SSE 关键头由流式分支显式设置)。 */
|
||||
const FORWARD_RESPONSE_HEADERS = ["content-type", "x-trace-id", "retry-after"] as const;
|
||||
|
||||
export interface UpstreamError {
|
||||
statusCode: number;
|
||||
code: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/** 判定上游响应是否为 SSE 流。 */
|
||||
function isEventStream(contentType: string | null): boolean {
|
||||
return (contentType ?? "").includes("text/event-stream");
|
||||
}
|
||||
|
||||
export async function proxyRequest(
|
||||
req: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
backendUrl: string,
|
||||
): Promise<void> {
|
||||
const headers: Record<string, string> = {};
|
||||
for (const name of FORWARD_REQUEST_HEADERS) {
|
||||
const value = req.headers[name];
|
||||
if (value != null) {
|
||||
headers[name] = Array.isArray(value) ? value.join(", ") : value;
|
||||
}
|
||||
}
|
||||
|
||||
const method = req.method;
|
||||
const body = method === "GET" || method === "HEAD" ? undefined : (req.body as Buffer);
|
||||
|
||||
let upstream: Response;
|
||||
try {
|
||||
upstream = await fetch(`${backendUrl}${req.url}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body && body.length > 0 ? new Uint8Array(body) : undefined,
|
||||
redirect: "manual",
|
||||
});
|
||||
} catch (err) {
|
||||
// 上游不可达:HJ-3001(API 契约 §5 错误结构)
|
||||
req.log.warn({ err: String(err) }, "上游不可达");
|
||||
await reply.code(502).send({
|
||||
error: { code: "HJ-3001", message: "上游服务不可达", request_id: req.id },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (upstream.body === null) {
|
||||
await writeBuffered(reply, upstream);
|
||||
return;
|
||||
}
|
||||
if (isEventStream(upstream.headers.get("content-type"))) {
|
||||
writeEventStream(req, reply, upstream);
|
||||
return;
|
||||
}
|
||||
await writeBuffered(reply, upstream);
|
||||
}
|
||||
|
||||
async function writeBuffered(reply: FastifyReply, upstream: Response): Promise<void> {
|
||||
for (const name of FORWARD_RESPONSE_HEADERS) {
|
||||
const value = upstream.headers.get(name);
|
||||
if (value != null) {
|
||||
reply.header(name, value);
|
||||
}
|
||||
}
|
||||
const payload = Buffer.from(await upstream.arrayBuffer());
|
||||
await reply.code(upstream.status).send(payload.length > 0 ? payload : null);
|
||||
}
|
||||
|
||||
function writeEventStream(req: FastifyRequest, reply: FastifyReply, upstream: Response): void {
|
||||
// 禁缓冲直通:hijack 后绕过 Fastify 序列化,逐块写底层连接
|
||||
reply.hijack();
|
||||
const raw = reply.raw;
|
||||
raw.writeHead(upstream.status, {
|
||||
"content-type": upstream.headers.get("content-type") ?? "text/event-stream; charset=utf-8",
|
||||
"cache-control": "no-cache",
|
||||
"x-accel-buffering": "no",
|
||||
});
|
||||
const nodeStream = Readable.fromWeb(upstream.body as Parameters<typeof Readable.fromWeb>[0]);
|
||||
nodeStream.on("error", (err) => {
|
||||
req.log.warn({ err: String(err) }, "上游流中断");
|
||||
raw.end();
|
||||
});
|
||||
nodeStream.pipe(raw);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/** 照壁服务装配与启动入口。 */
|
||||
import Fastify from "fastify";
|
||||
import { loadConfig, type BffConfig } from "./config.js";
|
||||
import { proxyRequest } from "./proxy.js";
|
||||
|
||||
export function buildServer(config: BffConfig) {
|
||||
const app = Fastify({
|
||||
logger: { level: config.logLevel },
|
||||
bodyLimit: config.bodyLimit,
|
||||
requestIdHeader: "x-request-id",
|
||||
genReqId: () => `req_${crypto.randomUUID().replace(/-/g, "").slice(0, 20)}`,
|
||||
});
|
||||
|
||||
// 透明代理:移除内建解析器,任意 Content-Type 一律按原始字节透传(含 multipart)。
|
||||
// 否则 application/json 会被内建解析器先解析成对象,转发时丢失原始字节形态。
|
||||
app.removeAllContentTypeParsers();
|
||||
app.addContentTypeParser(
|
||||
"*",
|
||||
{ parseAs: "buffer" },
|
||||
(_request, body, done: (err: Error | null, body?: Buffer) => void) => {
|
||||
done(null, body as Buffer);
|
||||
},
|
||||
);
|
||||
|
||||
app.get("/healthz", async () => ({ status: "ok" }));
|
||||
|
||||
app.all("/api/*", async (request, reply) => {
|
||||
await proxyRequest(request, reply, config.backendUrl);
|
||||
});
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
if (process.argv[1]?.endsWith("server.ts") || process.argv[1]?.endsWith("server.js")) {
|
||||
const config = loadConfig();
|
||||
const app = buildServer(config);
|
||||
app.listen({ port: config.port, host: "0.0.0.0" }).catch((err) => {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
/** 照壁代理行为测试:头透传、SSE 流顺序、断线续传头、上游错误与不可达映射。 */
|
||||
import http from "node:http";
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { buildServer } from "../src/server.js";
|
||||
|
||||
let upstream: http.Server;
|
||||
let upstreamUrl = "";
|
||||
let upstreamCalls: Array<{ method: string; url: string; headers: http.IncomingHttpHeaders; body: string }> = [];
|
||||
|
||||
beforeAll(async () => {
|
||||
upstream = http.createServer((req, res) => {
|
||||
let body = "";
|
||||
req.on("data", (chunk) => (body += chunk));
|
||||
req.on("end", () => {
|
||||
upstreamCalls.push({ method: req.method ?? "", url: req.url ?? "", headers: req.headers, body });
|
||||
if (req.url === "/api/v1/echo") {
|
||||
res.writeHead(200, { "content-type": "application/json", "x-trace-id": String(req.headers["x-trace-id"] ?? "") });
|
||||
res.end(JSON.stringify({ ok: true, auth: req.headers.authorization ?? null, body }));
|
||||
} else if (req.url === "/api/v1/stream") {
|
||||
res.writeHead(200, { "content-type": "text/event-stream; charset=utf-8" });
|
||||
const frames = ["id: 1\nevent: run.started\n\n", "id: 2\nevent: llm.delta\n\n", "id: 3\nevent: run.finished\n\n"];
|
||||
frames.forEach((frame, i) => setTimeout(() => res.write(frame), i * 20));
|
||||
setTimeout(() => res.end(), frames.length * 20 + 10);
|
||||
} else if (req.url === "/api/v1/boom") {
|
||||
res.writeHead(404, { "content-type": "application/json" });
|
||||
res.end(JSON.stringify({ error: { code: "HJ-1002", message: "不存在" } }));
|
||||
} else {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
res.end("{}");
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => upstream.listen(0, "127.0.0.1", resolve));
|
||||
upstreamUrl = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await new Promise<void>((resolve) => upstream.close(() => resolve()));
|
||||
});
|
||||
|
||||
function app() {
|
||||
return buildServer({
|
||||
port: 0,
|
||||
backendUrl: upstreamUrl,
|
||||
bodyLimit: 1024 * 1024,
|
||||
logLevel: "silent",
|
||||
});
|
||||
}
|
||||
|
||||
describe("照壁代理", () => {
|
||||
it("转发 Authorization 与 Idempotency-Key,透传响应体与 x-trace-id", async () => {
|
||||
const res = await app().inject({
|
||||
method: "POST",
|
||||
url: "/api/v1/echo",
|
||||
headers: { authorization: "Bearer tok", "idempotency-key": "idem-1", "x-trace-id": "trace-9" },
|
||||
payload: JSON.stringify({ a: 1 }),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.auth).toBe("Bearer tok");
|
||||
expect(body.body).toBe(JSON.stringify({ a: 1 }));
|
||||
expect(res.headers["x-trace-id"]).toBe("trace-9");
|
||||
const call = upstreamCalls.at(-1)!;
|
||||
expect(call.headers["authorization"]).toBe("Bearer tok");
|
||||
expect(call.headers["idempotency-key"]).toBe("idem-1");
|
||||
});
|
||||
|
||||
it("SSE 流式透传:帧序完整、禁缓冲头、Last-Event-ID 透传", async () => {
|
||||
const res = await app().inject({
|
||||
method: "GET",
|
||||
url: "/api/v1/stream",
|
||||
headers: { "last-event-id": "2", authorization: "Bearer tok" },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.headers["content-type"]).toContain("text/event-stream");
|
||||
expect(res.headers["x-accel-buffering"]).toBe("no");
|
||||
expect(res.body.indexOf("run.started")).toBeLessThan(res.body.indexOf("run.finished"));
|
||||
const call = upstreamCalls.at(-1)!;
|
||||
expect(call.headers["last-event-id"]).toBe("2");
|
||||
});
|
||||
|
||||
it("上游错误状态与错误体原样透传", async () => {
|
||||
const res = await app().inject({ method: "GET", url: "/api/v1/boom" });
|
||||
expect(res.statusCode).toBe(404);
|
||||
expect(res.json().error.code).toBe("HJ-1002");
|
||||
});
|
||||
|
||||
it("上游不可达映射 HJ-3001", async () => {
|
||||
const dead = buildServer({
|
||||
port: 0,
|
||||
backendUrl: "http://127.0.0.1:1",
|
||||
bodyLimit: 1024,
|
||||
logLevel: "silent",
|
||||
});
|
||||
const res = await dead.inject({ method: "GET", url: "/api/v1/echo" });
|
||||
expect(res.statusCode).toBe(502);
|
||||
expect(res.json().error.code).toBe("HJ-3001");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2023",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"lib": ["ES2023"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ["test/**/*.test.ts"],
|
||||
testTimeout: 10_000,
|
||||
},
|
||||
});
|
||||
Reference in new issue
Block a user