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:
home
2026-02-23 15:10:38 +03:00
parent 8fc82a3b90
commit cd6b7857ba
606 changed files with 26148 additions and 14297 deletions

View File

@@ -0,0 +1,21 @@
import express from 'express';
import cors from 'cors';
import contextRouter from './routes/context.js';
const app = express();
app.use(cors({ origin: true }));
const PORT = parseInt(process.env.PORT ?? '3015', 10);
app.use(express.json({ limit: '1mb' }));
app.use('/api', contextRouter);
app.get('/health', (_, res) => res.json({ status: 'ok', service: 'geo-device-svc' }));
app.get('/ready', (_, res) => res.json({ status: 'ready' }));
app.listen(PORT, '0.0.0.0', () => {
console.log(`geo-device-svc listening on :${PORT}`);
console.log(` Context (GET): /api/context`);
console.log(` Context (POST): /api/context (with client data in body)`);
});

View File

@@ -0,0 +1,69 @@
import type { GeoDeviceContext, GeoLocation, ClientContext } from '../types.js';
import { lookupByIP } from './geo.js';
import { parseUserAgent } from './device.js';
const getClientIP = (req: { headers: Record<string, string | string[] | undefined> }): string => {
const forwarded = req.headers['x-forwarded-for'];
if (typeof forwarded === 'string') {
return forwarded.split(',')[0].trim();
}
if (Array.isArray(forwarded)) {
return (forwarded[0] ?? '').split(',')[0].trim();
}
return (req.headers['x-real-ip'] as string) ?? '';
};
export const buildContext = (
req: { headers: Record<string, string | string[] | undefined>; socket?: { remoteAddress?: string } },
clientData?: Partial<{
geo: Partial<GeoLocation>;
client: Partial<ClientContext>;
}>,
): GeoDeviceContext => {
const ip = getClientIP(req) || req.socket?.remoteAddress?.replace(/^::ffff:/, '') || '';
const userAgent = (req.headers['user-agent'] as string) ?? '';
const acceptLanguage = (req.headers['accept-language'] as string) ?? undefined;
const { device, browser, os } = parseUserAgent(userAgent);
let geo: GeoLocation | null = clientData?.geo
? {
latitude: clientData.geo.latitude ?? 0,
longitude: clientData.geo.longitude ?? 0,
country: clientData.geo.country ?? '',
countryCode: clientData.geo.countryCode ?? '',
region: clientData.geo.region ?? '',
city: clientData.geo.city ?? '',
timezone: clientData.geo.timezone ?? '',
source: 'client',
}
: lookupByIP(ip);
const client: ClientContext = {
screenWidth: clientData?.client?.screenWidth,
screenHeight: clientData?.client?.screenHeight,
viewportWidth: clientData?.client?.viewportWidth,
viewportHeight: clientData?.client?.viewportHeight,
devicePixelRatio: clientData?.client?.devicePixelRatio,
timezone: clientData?.client?.timezone,
language: clientData?.client?.language,
languages: clientData?.client?.languages,
platform: clientData?.client?.platform,
hardwareConcurrency: clientData?.client?.hardwareConcurrency,
deviceMemory: clientData?.client?.deviceMemory,
cookieEnabled: clientData?.client?.cookieEnabled,
doNotTrack: clientData?.client?.doNotTrack,
};
return {
geo,
device,
browser,
os,
client,
ip: ip || undefined,
userAgent,
acceptLanguage,
requestedAt: new Date().toISOString(),
};
};

View File

@@ -0,0 +1,35 @@
import UAParser from 'ua-parser-js';
import type { DeviceInfo, BrowserInfo, OSInfo } from '../types.js';
export const parseUserAgent = (ua: string): {
device: DeviceInfo;
browser: BrowserInfo;
os: OSInfo;
} => {
const parser = new UAParser(ua);
const result = parser.getResult();
const deviceType = result.device.type as DeviceInfo['type'] | undefined;
const type: DeviceInfo['type'] =
deviceType && ['mobile', 'tablet', 'wearable', 'smarttv', 'desktop'].includes(deviceType)
? deviceType
: 'desktop';
return {
device: {
type,
vendor: result.device.vendor || undefined,
model: result.device.model || undefined,
},
browser: {
name: result.browser.name ?? 'Unknown',
version: result.browser.version ?? '',
full: [result.browser.name, result.browser.version].filter(Boolean).join(' '),
},
os: {
name: result.os.name ?? 'Unknown',
version: result.os.version ?? '',
full: [result.os.name, result.os.version].filter(Boolean).join(' '),
},
};
};

View File

@@ -0,0 +1,22 @@
import geoip from 'geoip-lite';
import type { GeoLocation } from '../types.js';
export const lookupByIP = (ip: string): GeoLocation | null => {
if (!ip || ip === '::1' || ip === '127.0.0.1') {
return null;
}
const result = geoip.lookup(ip);
if (!result) return null;
return {
latitude: result.ll?.[0] ?? 0,
longitude: result.ll?.[1] ?? 0,
country: result.country ?? '',
countryCode: result.country ?? '',
region: result.region ?? '',
city: result.city ?? '',
timezone: result.timezone ?? '',
source: 'ip',
};
};

View File

@@ -0,0 +1,54 @@
import { Router, type Request, type Response } from 'express';
import { buildContext } from '../lib/context.js';
const router = Router();
interface ClientBody {
geo?: {
latitude?: number;
longitude?: number;
country?: string;
countryCode?: string;
region?: string;
city?: string;
timezone?: string;
};
client?: {
screenWidth?: number;
screenHeight?: number;
viewportWidth?: number;
viewportHeight?: number;
devicePixelRatio?: number;
timezone?: string;
language?: string;
languages?: string[];
platform?: string;
hardwareConcurrency?: number;
deviceMemory?: number;
cookieEnabled?: boolean;
doNotTrack?: string | null;
};
}
router.get('/context', (req: Request, res: Response) => {
try {
const context = buildContext(req);
res.json(context);
} catch (err) {
console.error('Error building context:', err);
res.status(500).json({ error: 'Failed to build context' });
}
});
router.post('/context', (req: Request<object, object, ClientBody>, res: Response) => {
try {
const clientData = req.body;
const context = buildContext(req, clientData);
res.json(context);
} catch (err) {
console.error('Error building context:', err);
res.status(500).json({ error: 'Failed to build context' });
}
});
export default router;

View File

@@ -0,0 +1,56 @@
export interface GeoLocation {
latitude: number;
longitude: number;
country: string;
countryCode: string;
region: string;
city: string;
timezone: string;
source: 'ip' | 'client' | 'unknown';
}
export interface DeviceInfo {
type: 'desktop' | 'mobile' | 'tablet' | 'wearable' | 'smarttv' | 'unknown';
vendor?: string;
model?: string;
}
export interface BrowserInfo {
name: string;
version: string;
full: string;
}
export interface OSInfo {
name: string;
version: string;
full: string;
}
export interface ClientContext {
screenWidth?: number;
screenHeight?: number;
viewportWidth?: number;
viewportHeight?: number;
devicePixelRatio?: number;
timezone?: string;
language?: string;
languages?: string[];
platform?: string;
hardwareConcurrency?: number;
deviceMemory?: number;
cookieEnabled?: boolean;
doNotTrack?: string | null;
}
export interface GeoDeviceContext {
geo: GeoLocation | null;
device: DeviceInfo;
browser: BrowserInfo;
os: OSInfo;
client: ClientContext;
ip?: string;
userAgent: string;
acceptLanguage?: string;
requestedAt: string;
}