feat: default locale Russian, geo determines language for other countries
- localization-svc: defaultLocale ru, resolveLocale only by geo - web-svc: DEFAULT_LOCALE ru, layout lang=ru, embeddedTranslations fallback ru - countryToLocale: default ru when no country or unknown country Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
16
services/api-gateway/Dockerfile
Normal file
16
services/api-gateway/Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine
|
||||
WORKDIR /app
|
||||
COPY package.json ./
|
||||
RUN npm install --omit=dev
|
||||
COPY --from=builder /app/dist ./dist
|
||||
EXPOSE 3015
|
||||
ENV PORT=3015
|
||||
CMD ["node", "dist/index.js"]
|
||||
22
services/api-gateway/package.json
Normal file
22
services/api-gateway/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "api-gateway",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js",
|
||||
"lint": "eslint src --ext .ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.5",
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"fastify": "^4.28.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
156
services/api-gateway/src/index.ts
Normal file
156
services/api-gateway/src/index.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
import { config } from 'dotenv';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
config({ path: path.resolve(fileURLToPath(import.meta.url), '../../../../.env') });
|
||||
|
||||
/**
|
||||
* api-gateway — прокси к микросервисам
|
||||
* web-svc = только UI, вся логика и API здесь
|
||||
* docs/architecture: 02-k3s-microservices-spec.md §5
|
||||
*/
|
||||
|
||||
import Fastify, { FastifyRequest, FastifyReply } from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
|
||||
const PORT = parseInt(process.env.PORT ?? '3015', 10);
|
||||
|
||||
const SVC: Record<string, string> = {
|
||||
chat: process.env.CHAT_SVC_URL ?? 'http://localhost:3005',
|
||||
library: process.env.LIBRARY_SVC_URL ?? 'http://localhost:3009',
|
||||
discover: process.env.DISCOVER_SVC_URL ?? 'http://localhost:3002',
|
||||
search: process.env.SEARCH_SVC_URL ?? 'http://localhost:3001',
|
||||
finance: process.env.FINANCE_SVC_URL ?? 'http://localhost:3003',
|
||||
travel: process.env.TRAVEL_SVC_URL ?? 'http://localhost:3004',
|
||||
create: process.env.CREATE_SVC_URL ?? 'http://localhost:3011',
|
||||
memory: process.env.MEMORY_SVC_URL ?? 'http://localhost:3010',
|
||||
projects: process.env.PROJECTS_SVC_URL ?? 'http://localhost:3006',
|
||||
notifications: process.env.NOTIFICATIONS_SVC_URL ?? 'http://localhost:3013',
|
||||
billing: process.env.BILLING_SVC_URL ?? 'http://localhost:3008',
|
||||
audit: process.env.AUDIT_SVC_URL ?? 'http://localhost:3012',
|
||||
auth: process.env.AUTH_SVC_URL ?? 'http://localhost:3014',
|
||||
localization: process.env.LOCALIZATION_SVC_URL ?? 'http://localhost:4003',
|
||||
geo: process.env.GEO_DEVICE_URL ?? 'http://localhost:4002',
|
||||
media: process.env.MEDIA_SVC_URL ?? 'http://localhost:3016',
|
||||
suggestions: process.env.SUGGESTIONS_SVC_URL ?? 'http://localhost:3017',
|
||||
agents: process.env.MASTER_AGENTS_SVC_URL ?? 'http://localhost:3018',
|
||||
profile: process.env.PROFILE_SVC_URL ?? 'http://localhost:3019',
|
||||
llm: process.env.LLM_SVC_URL ?? 'http://localhost:3020',
|
||||
};
|
||||
|
||||
function getTarget(path: string): { base: string; rewrite: string } | null {
|
||||
if (path === '/api/chat' || path.startsWith('/api/chat?')) return { base: SVC.chat, rewrite: '/api/v1/chat' };
|
||||
if (path.startsWith('/api/v1/library')) return { base: SVC.library, rewrite: path };
|
||||
if (path.startsWith('/api/v1/discover')) return { base: SVC.discover, rewrite: path };
|
||||
if (path.startsWith('/api/v1/search')) return { base: SVC.search, rewrite: path };
|
||||
if (path.startsWith('/api/v1/finance')) return { base: SVC.finance, rewrite: path };
|
||||
if (path.startsWith('/api/v1/travel')) return { base: SVC.travel, rewrite: path };
|
||||
if (path.startsWith('/api/v1/create') || path.startsWith('/api/v1/export')) return { base: SVC.create, rewrite: path };
|
||||
if (path.startsWith('/api/v1/memory')) return { base: SVC.memory, rewrite: path };
|
||||
if (path.startsWith('/api/v1/tasks')) return { base: SVC.chat, rewrite: path };
|
||||
if (path.startsWith('/api/v1/templates') || path.startsWith('/api/v1/connectors') || path.startsWith('/api/v1/collections')) return { base: SVC.projects, rewrite: path };
|
||||
if (path.startsWith('/api/v1/notifications')) return { base: SVC.notifications, rewrite: path };
|
||||
if (path.startsWith('/api/v1/billing')) return { base: SVC.billing, rewrite: path };
|
||||
if (path.startsWith('/api/v1/admin')) return { base: SVC.audit, rewrite: path };
|
||||
if (path.startsWith('/api/auth')) return { base: SVC.auth, rewrite: path };
|
||||
if (path.startsWith('/api/locale') || path.startsWith('/api/translations')) return { base: SVC.localization, rewrite: path };
|
||||
if (path.startsWith('/api/geo-context')) return { base: SVC.geo, rewrite: '/api/context' };
|
||||
if (path === '/api/images') return { base: SVC.media, rewrite: '/api/v1/media/images' };
|
||||
if (path === '/api/videos') return { base: SVC.media, rewrite: '/api/v1/media/videos' };
|
||||
if (path === '/api/suggestions') return { base: SVC.suggestions, rewrite: '/api/v1/suggestions' };
|
||||
if (path.startsWith('/api/v1/agents')) return { base: SVC.agents, rewrite: path };
|
||||
if (path.startsWith('/api/v1/config')) return { base: SVC.chat, rewrite: path };
|
||||
if (path.startsWith('/api/v1/providers')) return { base: SVC.llm, rewrite: path };
|
||||
if (path.startsWith('/api/v1/uploads')) return { base: SVC.chat, rewrite: path };
|
||||
if (path.startsWith('/api/v1/weather')) return { base: SVC.travel, rewrite: path };
|
||||
if (path.startsWith('/api/v1/profile')) return { base: SVC.profile, rewrite: path };
|
||||
return null;
|
||||
}
|
||||
|
||||
async function proxyRequest(req: FastifyRequest, reply: FastifyReply, stream = false) {
|
||||
const url = new URL(req.url, `http://${req.headers.host}`);
|
||||
const path = url.pathname;
|
||||
const target = getTarget(path);
|
||||
if (!target) {
|
||||
return reply.status(404).send({ error: 'Not found' });
|
||||
}
|
||||
const fullUrl = `${target.base.replace(/\/$/, '')}${target.rewrite}${url.search}`;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const pass = ['authorization', 'content-type', 'accept', 'x-forwarded-for', 'x-real-ip', 'user-agent', 'accept-language'];
|
||||
for (const h of pass) {
|
||||
const v = req.headers[h];
|
||||
if (v && typeof v === 'string') headers[h] = v;
|
||||
}
|
||||
if (!headers['Content-Type'] && req.method !== 'GET') headers['Content-Type'] = 'application/json';
|
||||
|
||||
try {
|
||||
const method = req.method;
|
||||
let body: string | undefined;
|
||||
if (method !== 'GET' && method !== 'HEAD') {
|
||||
const b = (req as any).body;
|
||||
body = typeof b === 'string' ? b : (b != null ? JSON.stringify(b) : '');
|
||||
}
|
||||
const init: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
signal: AbortSignal.timeout(path === '/api/chat' ? 120000 : 30000),
|
||||
};
|
||||
const res = await fetch(fullUrl, init);
|
||||
|
||||
const passthroughHeaders = [
|
||||
'content-type',
|
||||
'content-disposition',
|
||||
'cache-control',
|
||||
'connection',
|
||||
'set-auth-token', // better-auth bearer token для API
|
||||
'set-cookie', // better-auth session cookies
|
||||
];
|
||||
for (const [k, v] of res.headers.entries()) {
|
||||
const l = k.toLowerCase();
|
||||
if (passthroughHeaders.includes(l)) {
|
||||
reply.header(k, v);
|
||||
}
|
||||
}
|
||||
reply.status(res.status);
|
||||
|
||||
if (stream && res.body) {
|
||||
return reply.send(res.body as any);
|
||||
}
|
||||
const data = await res.text();
|
||||
const ct = res.headers.get('content-type') ?? '';
|
||||
if (ct.includes('application/json')) {
|
||||
try {
|
||||
return reply.send(JSON.parse(data));
|
||||
} catch {
|
||||
return reply.send(data);
|
||||
}
|
||||
}
|
||||
return reply.send(data);
|
||||
} catch (err: unknown) {
|
||||
req.log.error(err);
|
||||
return reply.status(503).send({ error: 'Service unavailable' });
|
||||
}
|
||||
}
|
||||
|
||||
const app = Fastify({ logger: true });
|
||||
const corsOrigin = process.env.ALLOWED_ORIGINS
|
||||
? process.env.ALLOWED_ORIGINS.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
: true;
|
||||
await app.register(cors, { origin: corsOrigin });
|
||||
|
||||
app.get('/health', async () => ({ status: 'ok' }));
|
||||
app.get('/ready', async () => ({ status: 'ready' }));
|
||||
|
||||
app.post('/api/chat', async (req, reply) => proxyRequest(req, reply, true));
|
||||
app.all('/api/*', async (req, reply) => proxyRequest(req, reply, false));
|
||||
|
||||
try {
|
||||
await app.listen({ port: PORT, host: '0.0.0.0' });
|
||||
console.log(`api-gateway listening on :${PORT}`);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
15
services/api-gateway/tsconfig.json
Normal file
15
services/api-gateway/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"resolveJsonModule": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user