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; }