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:
21
services/geo-device-svc/src/index.ts
Normal file
21
services/geo-device-svc/src/index.ts
Normal 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)`);
|
||||
});
|
||||
69
services/geo-device-svc/src/lib/context.ts
Normal file
69
services/geo-device-svc/src/lib/context.ts
Normal 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(),
|
||||
};
|
||||
};
|
||||
35
services/geo-device-svc/src/lib/device.ts
Normal file
35
services/geo-device-svc/src/lib/device.ts
Normal 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(' '),
|
||||
},
|
||||
};
|
||||
};
|
||||
22
services/geo-device-svc/src/lib/geo.ts
Normal file
22
services/geo-device-svc/src/lib/geo.ts
Normal 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',
|
||||
};
|
||||
};
|
||||
54
services/geo-device-svc/src/routes/context.ts
Normal file
54
services/geo-device-svc/src/routes/context.ts
Normal 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;
|
||||
56
services/geo-device-svc/src/types.ts
Normal file
56
services/geo-device-svc/src/types.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user