Files
Genarrative/server-node/src/app.ts
2026-04-22 20:14:15 +08:00

241 lines
7.2 KiB
TypeScript

import express from 'express';
import pinoHttp from 'pino-http';
import type { AppContext } from './context.js';
import { notFound } from './errors.js';
import { buildApiLogContext, withRouteMeta } from './http.js';
import { errorHandler } from './middleware/errorHandler.js';
import { requestIdMiddleware } from './middleware/requestId.js';
import { responseEnvelopeMiddleware } from './middleware/responseEnvelope.js';
import { createCharacterAssetRoutes } from './modules/assets/characterAssetRoutes.js';
import { createEditorRoutes } from './modules/editor/editorRoutes.js';
import { createAuthRoutes } from './routes/authRoutes.js';
import { createBigFishProxyRoutes } from './routes/bigFishProxyRoutes.js';
import { createCustomWorldAgentRoutes } from './routes/customWorldAgent.js';
import { createPuzzleProxyRoutes } from './routes/puzzleProxyRoutes.js';
import { createRpgEntrySaveRoutes } from './routes/rpg-entry/rpgEntrySaveRoutes.js';
import { createRpgWorldLibraryRoutes } from './routes/rpg-entry/rpgWorldLibraryRoutes.js';
import { createRpgProfileRoutes } from './routes/rpg-profile/rpgProfileRoutes.js';
import { createRpgRuntimeAiAssistRoutes } from './routes/rpg-runtime/rpgRuntimeAiAssistRoutes.js';
import { createRpgRuntimeStoryRoutes } from './routes/rpg-runtime/rpgRuntimeStoryRoutes.js';
function matchesRoutePrefix(
request: express.Request,
prefixes: readonly string[],
) {
const requestPath = request.path || request.originalUrl || request.url || '/';
return prefixes.some((prefix) => {
const normalizedPrefix = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
return (
requestPath === normalizedPrefix ||
requestPath.startsWith(`${normalizedPrefix}/`)
);
});
}
function scopeToPrefixes(
prefixes: readonly string[],
handler: express.RequestHandler,
): express.RequestHandler {
return (request, response, next) => {
if (!matchesRoutePrefix(request, prefixes)) {
next();
return;
}
handler(request, response, next);
};
}
export function createApp(context: AppContext) {
const app = express();
const createHttpLogger = pinoHttp as unknown as (
options: Record<string, unknown>,
) => express.RequestHandler;
app.disable('x-powered-by');
app.use(requestIdMiddleware);
app.use(
createHttpLogger({
logger: context.logger,
genReqId: (request: express.Request) => request.requestId,
customSuccessObject: (
request: express.Request,
response: express.Response,
baseObject: Record<string, unknown> & { responseTime?: number },
) => ({
...baseObject,
...buildApiLogContext(request, response),
user_id: request.userId ?? null,
method: request.method,
path: request.url,
status: response.statusCode,
latency_ms: baseObject.responseTime,
}),
customErrorObject: (
request: express.Request,
response: express.Response,
error: unknown,
baseObject: Record<string, unknown> & { responseTime?: number },
) => ({
...baseObject,
...buildApiLogContext(request, response),
user_id: request.userId ?? null,
method: request.method,
path: request.url,
status: response.statusCode,
latency_ms: baseObject.responseTime,
err: error,
}),
}),
);
app.use(express.json({ limit: '10mb' }));
app.use(responseEnvelopeMiddleware);
app.get(
'/healthz',
withRouteMeta({ operation: 'health.check' }),
(_request, response) => {
response.json({
ok: true,
service: 'genarrative-node-server',
});
},
);
app.use(
scopeToPrefixes(
['/api/editor'],
withRouteMeta({ routeVersion: '2026-04-08', operation: 'editor.api' }),
),
);
app.use(scopeToPrefixes(['/api/editor'], createEditorRoutes(context.config)));
app.use(
scopeToPrefixes(
['/api/assets'],
withRouteMeta({ routeVersion: '2026-04-08', operation: 'assets.api' }),
),
);
app.use(
scopeToPrefixes(
['/api/assets'],
createCharacterAssetRoutes(context.config, context.llmClient),
),
);
app.use(
'/api',
scopeToPrefixes(
['/runtime/profile', '/profile', '/runtime/settings'],
withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.profile.api' }),
),
createRpgProfileRoutes(context),
);
app.use(
'/api',
scopeToPrefixes(
['/runtime/save', '/runtime/profile/save-archives', '/profile/save-archives'],
withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.entry.save.api' }),
),
createRpgEntrySaveRoutes(context),
);
app.use(
'/api',
scopeToPrefixes(
['/runtime/custom-world-gallery', '/runtime/custom-world/works', '/runtime/custom-world-library'],
withRouteMeta({
routeVersion: '2026-04-21',
operation: 'rpg.entry.worldLibrary.api',
}),
),
createRpgWorldLibraryRoutes(context),
);
app.use(
'/api/auth',
withRouteMeta({ routeVersion: '2026-04-08' }),
createAuthRoutes(context),
);
app.use(
'/api/runtime/story',
withRouteMeta({ routeVersion: '2026-04-21' }),
createRpgRuntimeStoryRoutes(context),
);
app.use(
scopeToPrefixes(
[
'/llm/chat/completions',
'/custom-world/cover-image',
'/custom-world/cover-upload',
'/custom-world/scene-image',
'/custom-world/entity',
'/custom-world/scene-npc',
'/runtime/custom-world/entity',
'/runtime/custom-world/scene-npc',
'/runtime/custom-world/profile',
'/runtime/story/initial',
'/runtime/story/continue',
'/runtime/chat',
'/runtime/items',
'/runtime/quests',
'/ws/health',
],
withRouteMeta({
routeVersion: '2026-04-21',
operation: 'rpg.runtime.aiAssist.api',
}),
),
);
app.use(
'/api',
scopeToPrefixes(
[
'/llm/chat/completions',
'/custom-world/cover-image',
'/custom-world/cover-upload',
'/custom-world/scene-image',
'/custom-world/entity',
'/custom-world/scene-npc',
'/runtime/custom-world/entity',
'/runtime/custom-world/scene-npc',
'/runtime/custom-world/profile',
'/runtime/story/initial',
'/runtime/story/continue',
'/runtime/chat',
'/runtime/items',
'/runtime/quests',
'/ws/health',
],
createRpgRuntimeAiAssistRoutes(context),
),
);
app.use(
'/api/runtime/custom-world/agent',
withRouteMeta({ routeVersion: '2026-04-21', operation: 'rpg.creation.agent.api' }),
createCustomWorldAgentRoutes(context),
);
app.use(
'/api/runtime/big-fish',
withRouteMeta({ routeVersion: '2026-04-22', operation: 'bigFish.runtime.proxy.api' }),
createBigFishProxyRoutes(context),
);
app.use(
'/api/runtime/puzzle',
withRouteMeta({ routeVersion: '2026-04-22', operation: 'puzzle.runtime.proxy.api' }),
createPuzzleProxyRoutes(context),
);
app.use(
express.static(context.config.publicDir, {
fallthrough: true,
index: false,
}),
);
app.use((request, _response, next) => {
next(notFound(`接口不存在:${request.method} ${request.originalUrl}`));
});
app.use(errorHandler);
return app;
}