440 lines
12 KiB
TypeScript
440 lines
12 KiB
TypeScript
import { Readable } from 'node:stream';
|
|
|
|
import { type Request, type Response, Router } from 'express';
|
|
|
|
import type { AppContext } from '../context.js';
|
|
import { badRequest, upstreamError } from '../errors.js';
|
|
import {
|
|
API_RESPONSE_ENVELOPE_HEADER,
|
|
API_VERSION_HEADER,
|
|
asyncHandler,
|
|
prepareApiResponse,
|
|
RESPONSE_TIME_HEADER,
|
|
ROUTE_VERSION_HEADER,
|
|
} from '../http.js';
|
|
import { requireJwtAuth } from '../middleware/auth.js';
|
|
import { routeMeta } from '../middleware/routeMeta.js';
|
|
|
|
const PUZZLE_ROUTE_VERSION = '2026-04-22';
|
|
const DEFAULT_RUST_API_TARGET = 'http://127.0.0.1:3100';
|
|
const DEFAULT_INTERNAL_API_SECRET = 'genarrative-dev-internal-bridge';
|
|
const INTERNAL_USER_HEADER = 'x-genarrative-authenticated-user-id';
|
|
const INTERNAL_SECRET_HEADER = 'x-genarrative-internal-api-secret';
|
|
|
|
function resolveRustApiTarget(context: AppContext) {
|
|
const configured =
|
|
context.config.rawEnv.GENARRATIVE_API_TARGET?.trim() ||
|
|
context.config.rawEnv.RUST_API_SERVER_TARGET?.trim() ||
|
|
'';
|
|
return configured || DEFAULT_RUST_API_TARGET;
|
|
}
|
|
|
|
function resolveInternalApiSecret(context: AppContext) {
|
|
return (
|
|
context.config.rawEnv.GENARRATIVE_INTERNAL_API_SECRET?.trim() ||
|
|
DEFAULT_INTERNAL_API_SECRET
|
|
);
|
|
}
|
|
|
|
function normalizeRouteSuffix(path: string) {
|
|
const normalized = path.startsWith('/') ? path : `/${path}`;
|
|
return normalized.replace(/\/+$/u, '');
|
|
}
|
|
|
|
function buildUpstreamUrl(context: AppContext, pathSuffix: string) {
|
|
const baseUrl = resolveRustApiTarget(context).replace(/\/+$/u, '');
|
|
return `${baseUrl}${normalizeRouteSuffix(pathSuffix)}`;
|
|
}
|
|
|
|
function pickForwardHeaders(
|
|
request: Request,
|
|
context: AppContext,
|
|
userId: string,
|
|
) {
|
|
const forwardedHeaders = new Headers();
|
|
|
|
const contentType = request.header('content-type')?.trim();
|
|
if (contentType) {
|
|
forwardedHeaders.set('content-type', contentType);
|
|
}
|
|
|
|
const accept = request.header('accept')?.trim();
|
|
if (accept) {
|
|
forwardedHeaders.set('accept', accept);
|
|
}
|
|
|
|
const requestId = request.requestId?.trim();
|
|
if (requestId) {
|
|
forwardedHeaders.set('x-request-id', requestId);
|
|
}
|
|
|
|
const envelope = request.header(API_RESPONSE_ENVELOPE_HEADER)?.trim();
|
|
if (envelope) {
|
|
forwardedHeaders.set(API_RESPONSE_ENVELOPE_HEADER, envelope);
|
|
}
|
|
|
|
forwardedHeaders.set(INTERNAL_USER_HEADER, userId);
|
|
const internalSecret = resolveInternalApiSecret(context);
|
|
if (internalSecret) {
|
|
forwardedHeaders.set(INTERNAL_SECRET_HEADER, internalSecret);
|
|
}
|
|
return forwardedHeaders;
|
|
}
|
|
|
|
function readBodyAllowed(method: string) {
|
|
return !['GET', 'HEAD'].includes(method.toUpperCase());
|
|
}
|
|
|
|
async function proxyPuzzleRequest(params: {
|
|
context: AppContext;
|
|
request: Request;
|
|
response: Response;
|
|
pathSuffix: string;
|
|
streamBody?: boolean;
|
|
}) {
|
|
const { context, request, response, pathSuffix, streamBody = false } = params;
|
|
const userId = request.userId?.trim();
|
|
if (!userId) {
|
|
throw badRequest('缺少已认证用户上下文');
|
|
}
|
|
|
|
const upstreamUrl = buildUpstreamUrl(context, pathSuffix);
|
|
const method = request.method.toUpperCase();
|
|
const body =
|
|
readBodyAllowed(method) && request.body !== undefined
|
|
? JSON.stringify(request.body)
|
|
: undefined;
|
|
|
|
let upstreamResponse: globalThis.Response;
|
|
try {
|
|
upstreamResponse = await fetch(upstreamUrl, {
|
|
method,
|
|
headers: pickForwardHeaders(request, context, userId),
|
|
body,
|
|
});
|
|
} catch (error) {
|
|
request.log?.error(
|
|
{
|
|
err: error,
|
|
user_id: userId,
|
|
upstream_url: upstreamUrl,
|
|
},
|
|
'puzzle upstream request failed',
|
|
);
|
|
throw upstreamError('拼图后端暂时不可用');
|
|
}
|
|
|
|
prepareApiResponse(request, response, {
|
|
statusCode: upstreamResponse.status,
|
|
headers: {
|
|
'Content-Type':
|
|
upstreamResponse.headers.get('content-type') ||
|
|
'application/json; charset=utf-8',
|
|
'Cache-Control':
|
|
upstreamResponse.headers.get('cache-control') || 'no-cache',
|
|
},
|
|
routeMeta: {
|
|
routeVersion: PUZZLE_ROUTE_VERSION,
|
|
},
|
|
});
|
|
|
|
const upstreamRequestId = upstreamResponse.headers.get('x-request-id');
|
|
if (upstreamRequestId) {
|
|
response.setHeader('x-upstream-request-id', upstreamRequestId);
|
|
}
|
|
|
|
const upstreamRouteVersion = upstreamResponse.headers.get(ROUTE_VERSION_HEADER);
|
|
if (upstreamRouteVersion) {
|
|
response.setHeader('x-upstream-route-version', upstreamRouteVersion);
|
|
}
|
|
|
|
const upstreamApiVersion = upstreamResponse.headers.get(API_VERSION_HEADER);
|
|
if (upstreamApiVersion) {
|
|
response.setHeader('x-upstream-api-version', upstreamApiVersion);
|
|
}
|
|
|
|
const upstreamLatency = upstreamResponse.headers.get(RESPONSE_TIME_HEADER);
|
|
if (upstreamLatency) {
|
|
response.setHeader('x-upstream-response-time-ms', upstreamLatency);
|
|
}
|
|
|
|
if (streamBody) {
|
|
if (!upstreamResponse.body) {
|
|
throw upstreamError('拼图流式响应不可用');
|
|
}
|
|
|
|
response.flushHeaders?.();
|
|
await Readable.fromWeb(upstreamResponse.body as never).pipe(response);
|
|
return;
|
|
}
|
|
|
|
response.end(await upstreamResponse.text());
|
|
}
|
|
|
|
function readParam(value: string | string[] | undefined) {
|
|
return Array.isArray(value) ? value[0]?.trim() || '' : value?.trim() || '';
|
|
}
|
|
|
|
export function createPuzzleProxyRoutes(context: AppContext) {
|
|
const router = Router();
|
|
const requireAuth = requireJwtAuth(context.config, context.userRepository);
|
|
|
|
router.use(requireAuth);
|
|
|
|
router.post(
|
|
'/agent/sessions',
|
|
routeMeta({ operation: 'runtime.puzzle.createSession', routeVersion: PUZZLE_ROUTE_VERSION }),
|
|
asyncHandler(async (request, response) => {
|
|
await proxyPuzzleRequest({
|
|
context,
|
|
request,
|
|
response,
|
|
pathSuffix: '/api/runtime/puzzle/agent/sessions',
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.get(
|
|
'/agent/sessions/:sessionId',
|
|
routeMeta({ operation: 'runtime.puzzle.getSession', routeVersion: PUZZLE_ROUTE_VERSION }),
|
|
asyncHandler(async (request, response) => {
|
|
const sessionId = readParam(request.params.sessionId);
|
|
if (!sessionId) {
|
|
throw badRequest('sessionId is required');
|
|
}
|
|
|
|
await proxyPuzzleRequest({
|
|
context,
|
|
request,
|
|
response,
|
|
pathSuffix: `/api/runtime/puzzle/agent/sessions/${encodeURIComponent(sessionId)}`,
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/agent/sessions/:sessionId/messages',
|
|
routeMeta({ operation: 'runtime.puzzle.sendMessage', routeVersion: PUZZLE_ROUTE_VERSION }),
|
|
asyncHandler(async (request, response) => {
|
|
const sessionId = readParam(request.params.sessionId);
|
|
if (!sessionId) {
|
|
throw badRequest('sessionId is required');
|
|
}
|
|
|
|
await proxyPuzzleRequest({
|
|
context,
|
|
request,
|
|
response,
|
|
pathSuffix: `/api/runtime/puzzle/agent/sessions/${encodeURIComponent(sessionId)}/messages`,
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/agent/sessions/:sessionId/messages/stream',
|
|
routeMeta({
|
|
operation: 'runtime.puzzle.streamMessage',
|
|
routeVersion: PUZZLE_ROUTE_VERSION,
|
|
}),
|
|
asyncHandler(async (request, response) => {
|
|
const sessionId = readParam(request.params.sessionId);
|
|
if (!sessionId) {
|
|
throw badRequest('sessionId is required');
|
|
}
|
|
|
|
await proxyPuzzleRequest({
|
|
context,
|
|
request,
|
|
response,
|
|
pathSuffix: `/api/runtime/puzzle/agent/sessions/${encodeURIComponent(sessionId)}/messages/stream`,
|
|
streamBody: true,
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/agent/sessions/:sessionId/actions',
|
|
routeMeta({ operation: 'runtime.puzzle.executeAction', routeVersion: PUZZLE_ROUTE_VERSION }),
|
|
asyncHandler(async (request, response) => {
|
|
const sessionId = readParam(request.params.sessionId);
|
|
if (!sessionId) {
|
|
throw badRequest('sessionId is required');
|
|
}
|
|
|
|
await proxyPuzzleRequest({
|
|
context,
|
|
request,
|
|
response,
|
|
pathSuffix: `/api/runtime/puzzle/agent/sessions/${encodeURIComponent(sessionId)}/actions`,
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.get(
|
|
'/works',
|
|
routeMeta({ operation: 'runtime.puzzle.listWorks', routeVersion: PUZZLE_ROUTE_VERSION }),
|
|
asyncHandler(async (request, response) => {
|
|
await proxyPuzzleRequest({
|
|
context,
|
|
request,
|
|
response,
|
|
pathSuffix: '/api/runtime/puzzle/works',
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.get(
|
|
'/works/:profileId',
|
|
routeMeta({ operation: 'runtime.puzzle.getWorkDetail', routeVersion: PUZZLE_ROUTE_VERSION }),
|
|
asyncHandler(async (request, response) => {
|
|
const profileId = readParam(request.params.profileId);
|
|
if (!profileId) {
|
|
throw badRequest('profileId is required');
|
|
}
|
|
|
|
await proxyPuzzleRequest({
|
|
context,
|
|
request,
|
|
response,
|
|
pathSuffix: `/api/runtime/puzzle/works/${encodeURIComponent(profileId)}`,
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.put(
|
|
'/works/:profileId',
|
|
routeMeta({ operation: 'runtime.puzzle.updateWork', routeVersion: PUZZLE_ROUTE_VERSION }),
|
|
asyncHandler(async (request, response) => {
|
|
const profileId = readParam(request.params.profileId);
|
|
if (!profileId) {
|
|
throw badRequest('profileId is required');
|
|
}
|
|
|
|
await proxyPuzzleRequest({
|
|
context,
|
|
request,
|
|
response,
|
|
pathSuffix: `/api/runtime/puzzle/works/${encodeURIComponent(profileId)}`,
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.get(
|
|
'/gallery',
|
|
routeMeta({ operation: 'runtime.puzzle.listGallery', routeVersion: PUZZLE_ROUTE_VERSION }),
|
|
asyncHandler(async (request, response) => {
|
|
await proxyPuzzleRequest({
|
|
context,
|
|
request,
|
|
response,
|
|
pathSuffix: '/api/runtime/puzzle/gallery',
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.get(
|
|
'/gallery/:profileId',
|
|
routeMeta({ operation: 'runtime.puzzle.getGalleryDetail', routeVersion: PUZZLE_ROUTE_VERSION }),
|
|
asyncHandler(async (request, response) => {
|
|
const profileId = readParam(request.params.profileId);
|
|
if (!profileId) {
|
|
throw badRequest('profileId is required');
|
|
}
|
|
|
|
await proxyPuzzleRequest({
|
|
context,
|
|
request,
|
|
response,
|
|
pathSuffix: `/api/runtime/puzzle/gallery/${encodeURIComponent(profileId)}`,
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/runs',
|
|
routeMeta({ operation: 'runtime.puzzle.startRun', routeVersion: PUZZLE_ROUTE_VERSION }),
|
|
asyncHandler(async (request, response) => {
|
|
await proxyPuzzleRequest({
|
|
context,
|
|
request,
|
|
response,
|
|
pathSuffix: '/api/runtime/puzzle/runs',
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.get(
|
|
'/runs/:runId',
|
|
routeMeta({ operation: 'runtime.puzzle.getRun', routeVersion: PUZZLE_ROUTE_VERSION }),
|
|
asyncHandler(async (request, response) => {
|
|
const runId = readParam(request.params.runId);
|
|
if (!runId) {
|
|
throw badRequest('runId is required');
|
|
}
|
|
|
|
await proxyPuzzleRequest({
|
|
context,
|
|
request,
|
|
response,
|
|
pathSuffix: `/api/runtime/puzzle/runs/${encodeURIComponent(runId)}`,
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/runs/:runId/swap',
|
|
routeMeta({ operation: 'runtime.puzzle.swapPieces', routeVersion: PUZZLE_ROUTE_VERSION }),
|
|
asyncHandler(async (request, response) => {
|
|
const runId = readParam(request.params.runId);
|
|
if (!runId) {
|
|
throw badRequest('runId is required');
|
|
}
|
|
|
|
await proxyPuzzleRequest({
|
|
context,
|
|
request,
|
|
response,
|
|
pathSuffix: `/api/runtime/puzzle/runs/${encodeURIComponent(runId)}/swap`,
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/runs/:runId/drag',
|
|
routeMeta({ operation: 'runtime.puzzle.dragPiece', routeVersion: PUZZLE_ROUTE_VERSION }),
|
|
asyncHandler(async (request, response) => {
|
|
const runId = readParam(request.params.runId);
|
|
if (!runId) {
|
|
throw badRequest('runId is required');
|
|
}
|
|
|
|
await proxyPuzzleRequest({
|
|
context,
|
|
request,
|
|
response,
|
|
pathSuffix: `/api/runtime/puzzle/runs/${encodeURIComponent(runId)}/drag`,
|
|
});
|
|
}),
|
|
);
|
|
|
|
router.post(
|
|
'/runs/:runId/next-level',
|
|
routeMeta({ operation: 'runtime.puzzle.nextLevel', routeVersion: PUZZLE_ROUTE_VERSION }),
|
|
asyncHandler(async (request, response) => {
|
|
const runId = readParam(request.params.runId);
|
|
if (!runId) {
|
|
throw badRequest('runId is required');
|
|
}
|
|
|
|
await proxyPuzzleRequest({
|
|
context,
|
|
request,
|
|
response,
|
|
pathSuffix: `/api/runtime/puzzle/runs/${encodeURIComponent(runId)}/next-level`,
|
|
});
|
|
}),
|
|
);
|
|
|
|
return router;
|
|
}
|