M0:照壁 BFF 骨架(Fastify 透明代理:鉴权/幂等/续传头透传、SSE 直通、HJ-3001 映射)及测试与镜像

This commit is contained in:
chenxin committed 2026-08-21 22:07:39 +08:00
1 parent f3058a64c3
commit 8d8d81e757
10 files changed
+2700

No files matched your search

+18
View File
@@ -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",
};
}
+103
View File
@@ -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);
}
+41
View File
@@ -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);
});
}