Skip to content

Commit cb6c8c3

Browse files
committed
fix: start a new server if the config is different
1 parent 2114c12 commit cb6c8c3

3 files changed

Lines changed: 308 additions & 7 deletions

File tree

Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { describe, expect, it } from "vitest";
2+
import { hashConfig, stableStringify } from "./translation-server";
3+
4+
describe("stableStringify", () => {
5+
describe("consistency - same input produces same output", () => {
6+
it("should produce identical output for identical objects", () => {
7+
const obj = { a: 1, b: 2, c: 3 };
8+
expect(stableStringify(obj)).toBe(stableStringify(obj));
9+
});
10+
11+
it("should produce identical output regardless of key order", () => {
12+
const obj1 = { a: 1, b: 2, c: 3 };
13+
const obj2 = { c: 3, a: 1, b: 2 };
14+
expect(stableStringify(obj1)).toBe(stableStringify(obj2));
15+
});
16+
17+
it("should handle nested objects consistently", () => {
18+
const obj1 = { a: 1, nested: { x: 10, y: 20 } };
19+
const obj2 = { nested: { y: 20, x: 10 }, a: 1 };
20+
expect(stableStringify(obj1)).toBe(stableStringify(obj2));
21+
});
22+
23+
it("should handle deeply nested objects", () => {
24+
const obj1 = {
25+
level1: {
26+
level2: {
27+
level3: { z: 3, y: 2, x: 1 },
28+
b: "beta",
29+
},
30+
a: "alpha",
31+
},
32+
};
33+
const obj2 = {
34+
level1: {
35+
a: "alpha",
36+
level2: {
37+
b: "beta",
38+
level3: { x: 1, y: 2, z: 3 },
39+
},
40+
},
41+
};
42+
expect(stableStringify(obj1)).toBe(stableStringify(obj2));
43+
});
44+
});
45+
46+
describe("arrays", () => {
47+
it("should preserve array order", () => {
48+
const obj1 = { arr: [1, 2, 3] };
49+
const obj2 = { arr: [3, 2, 1] };
50+
expect(stableStringify(obj1)).not.toBe(stableStringify(obj2));
51+
});
52+
53+
it("should handle arrays of objects", () => {
54+
const obj1 = {
55+
items: [
56+
{ b: 2, a: 1 },
57+
{ d: 4, c: 3 },
58+
],
59+
};
60+
const obj2 = {
61+
items: [
62+
{ a: 1, b: 2 },
63+
{ c: 3, d: 4 },
64+
],
65+
};
66+
expect(stableStringify(obj1)).toBe(stableStringify(obj2));
67+
});
68+
69+
it("should handle nested arrays", () => {
70+
const obj = {
71+
matrix: [
72+
[1, 2],
73+
[3, 4],
74+
],
75+
};
76+
const result = stableStringify(obj);
77+
expect(result).toContain("[[1,2],[3,4]]");
78+
});
79+
});
80+
81+
describe("filtering - removes non-serializable values", () => {
82+
it("should filter out undefined values", () => {
83+
const obj = { a: 1, b: undefined, c: 3 };
84+
const result = stableStringify(obj);
85+
expect(result).not.toContain("b");
86+
expect(result).toBe('{"a":1,"c":3}');
87+
});
88+
89+
it("should filter out undefined in arrays", () => {
90+
const obj = { arr: [1, undefined, 2, undefined, 3] };
91+
const result = stableStringify(obj);
92+
expect(result).toBe('{"arr":[1,2,3]}');
93+
});
94+
});
95+
96+
describe("complex nested structures", () => {
97+
it("should handle mixed nested types", () => {
98+
const obj = {
99+
string: "value",
100+
number: 123,
101+
boolean: true,
102+
null: null,
103+
array: [1, 2, { nested: "item" }],
104+
object: {
105+
deep: {
106+
deeper: {
107+
value: "deepest",
108+
},
109+
},
110+
},
111+
};
112+
const result = stableStringify(obj);
113+
expect(result).toContain('"string":"value"');
114+
expect(result).toContain('"number":123');
115+
expect(result).toContain('"boolean":true');
116+
expect(result).toContain('"null":null');
117+
expect(result).toContain('"value":"deepest"');
118+
});
119+
120+
it("should be deterministic across multiple calls", () => {
121+
const obj = {
122+
z: 26,
123+
a: 1,
124+
m: { y: 2, x: 1 },
125+
arr: [3, 2, 1],
126+
};
127+
const results = Array.from({ length: 10 }, () => stableStringify(obj));
128+
const allSame = results.every((r) => r === results[0]);
129+
expect(allSame).toBe(true);
130+
});
131+
});
132+
});
133+
134+
describe("hashConfig", () => {
135+
describe("stability - same config produces same hash", () => {
136+
it("should produce same hash for identical configs", () => {
137+
const config = {
138+
sourceLocale: "en",
139+
targetLocales: ["es", "fr"],
140+
sourceRoot: "src",
141+
};
142+
const hash1 = hashConfig(config);
143+
const hash2 = hashConfig(config);
144+
expect(hash1).toBe(hash2);
145+
});
146+
147+
it("should produce same hash regardless of key order", () => {
148+
const config1 = {
149+
sourceLocale: "en",
150+
targetLocales: ["es", "fr"],
151+
sourceRoot: "src",
152+
};
153+
const config2 = {
154+
targetLocales: ["es", "fr"],
155+
sourceRoot: "src",
156+
sourceLocale: "en",
157+
};
158+
const hash1 = hashConfig(config1);
159+
const hash2 = hashConfig(config2);
160+
expect(hash1).toBe(hash2);
161+
});
162+
163+
it("should produce same hash for nested objects with different key order", () => {
164+
const config1 = {
165+
sourceLocale: "en",
166+
dev: { port: 3000, host: "localhost" },
167+
models: { en_es: "model1" },
168+
};
169+
const config2 = {
170+
models: { en_es: "model1" },
171+
sourceLocale: "en",
172+
dev: { host: "localhost", port: 3000 },
173+
};
174+
expect(hashConfig(config1)).toBe(hashConfig(config2));
175+
});
176+
});
177+
178+
describe("differentiation - different configs produce different hashes", () => {
179+
it("should produce different hash when value changes", () => {
180+
const config1 = { sourceLocale: "en", targetLocales: ["es"] };
181+
const config2 = { sourceLocale: "en", targetLocales: ["fr"] };
182+
expect(hashConfig(config1)).not.toBe(hashConfig(config2));
183+
});
184+
185+
it("should produce different hash when nested value changes", () => {
186+
const config1 = {
187+
sourceLocale: "en",
188+
dev: { port: 3000 },
189+
};
190+
const config2 = {
191+
sourceLocale: "en",
192+
dev: { port: 4000 },
193+
};
194+
expect(hashConfig(config1)).not.toBe(hashConfig(config2));
195+
});
196+
197+
it("should produce different hash when key is added", () => {
198+
const config1 = { sourceLocale: "en" };
199+
const config2 = { sourceLocale: "en", sourceRoot: "src" };
200+
expect(hashConfig(config1)).not.toBe(hashConfig(config2));
201+
});
202+
203+
it("should produce different hash when array order changes", () => {
204+
const config1 = { targetLocales: ["es", "fr", "de"] };
205+
const config2 = { targetLocales: ["de", "es", "fr"] };
206+
expect(hashConfig(config1)).not.toBe(hashConfig(config2));
207+
});
208+
});
209+
210+
describe("real-world config examples", () => {
211+
it("should handle typical translation config", () => {
212+
const config = {
213+
sourceLocale: "en",
214+
targetLocales: ["es", "fr", "de"],
215+
sourceRoot: "src",
216+
lingoDir: ".lingo",
217+
models: "lingo.dev",
218+
pluralization: { enabled: true, model: "groq:llama3" },
219+
dev: {
220+
translationServerStartPort: 60000,
221+
usePseudotranslator: false,
222+
},
223+
};
224+
const hash = hashConfig(config);
225+
expect(hash).toMatch(/^[a-f0-9]{12}$/);
226+
});
227+
228+
it("should differentiate configs with different translator settings", () => {
229+
const config1 = {
230+
sourceLocale: "en",
231+
models: "lingo.dev",
232+
};
233+
const config2 = {
234+
sourceLocale: "en",
235+
models: { "en:es": "openai:gpt-4" },
236+
};
237+
expect(hashConfig(config1)).not.toBe(hashConfig(config2));
238+
});
239+
});
240+
});

cmp/compiler/src/translation-server/translation-server.ts

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,13 @@
55
* - Finds a free port automatically
66
* - Serves translations via:
77
* - GET /translations/:locale - Full dictionary (cached)
8-
* - GET /translations/:locale/:hash - Single hash translation
98
* - POST /translations/:locale (body: { hashes: string[] }) - Batch translation
109
* - Uses the same translation logic as middleware
1110
* - Can be started/stopped programmatically
1211
*/
1312

1413
import http from "http";
14+
import crypto from "crypto";
1515
import type { Socket } from "net";
1616
import { URL } from "url";
1717
import { WebSocket, WebSocketServer } from "ws";
@@ -58,6 +58,7 @@ export class TranslationServer {
5858
private url: string | undefined = undefined;
5959
private logger;
6060
private config: TranslationMiddlewareConfig;
61+
private configHash: string;
6162
private startPort: number;
6263
private onReadyCallback?: (port: number) => void;
6364
private onErrorCallback?: (error: Error) => void;
@@ -75,6 +76,7 @@ export class TranslationServer {
7576

7677
constructor(options: TranslationServerOptions) {
7778
this.config = options.config;
79+
this.configHash = hashConfig(options.config);
7880
this.startPort = options.startPort || 60000;
7981
this.onReadyCallback = options.onReady;
8082
this.onErrorCallback = options.onError;
@@ -508,6 +510,7 @@ export class TranslationServer {
508510

509511
/**
510512
* Check if a given URL is running our translation server by calling the health endpoint
513+
* Also verifies that the config hash matches to ensure compatible configuration
511514
*/
512515
private async checkIfTranslationServer(url: string): Promise<boolean> {
513516
return new Promise((resolve) => {
@@ -525,11 +528,18 @@ export class TranslationServer {
525528
// Check if response is valid and has the expected structure
526529
if (res.statusCode === 200) {
527530
const json = JSON.parse(data);
528-
// Our translation server returns { status: "ok", port: ... }
529-
if (json.status === "ok") {
530-
resolve(true);
531+
// Our translation server returns { status: "ok", port: ..., configHash: ... }
532+
// Check if config hash matches (if present)
533+
// If configHash is missing (old server), accept it for backward compatibility
534+
if (json.configHash && json.configHash !== this.configHash) {
535+
this.logger.warn(
536+
`Existing server has different config (hash: ${json.configHash} vs ${this.configHash}), will start new server`,
537+
);
538+
resolve(false);
531539
return;
532540
}
541+
resolve(true);
542+
return;
533543
}
534544
resolve(false);
535545
} catch (error) {
@@ -581,10 +591,14 @@ export class TranslationServer {
581591
return;
582592
}
583593

584-
// Health check endpoint
585594
if (url.pathname === "/health") {
586595
res.writeHead(200, { "Content-Type": "application/json" });
587-
res.end(JSON.stringify({ status: "ok", port: this.url }));
596+
res.end(
597+
JSON.stringify({
598+
port: this.url,
599+
configHash: this.configHash,
600+
}),
601+
);
588602
return;
589603
}
590604

@@ -775,6 +789,53 @@ export class TranslationServer {
775789
}
776790
}
777791

792+
type SerializablePrimitive = string | number | boolean | null | undefined;
793+
794+
type SerializableObject = {
795+
[key: string]: SerializableValue;
796+
};
797+
export type SerializableValue =
798+
| SerializablePrimitive
799+
| SerializableValue[]
800+
| SerializableObject;
801+
802+
export function stableStringify(
803+
value: Record<string, SerializableValue>,
804+
): string {
805+
const normalize = (v: any): any => {
806+
if (v === undefined) return undefined;
807+
if (typeof v === "function") return undefined;
808+
if (v === null) return null;
809+
810+
if (Array.isArray(v)) {
811+
return v.map(normalize).filter((x) => x !== undefined);
812+
}
813+
814+
if (typeof v === "object") {
815+
const out: Record<string, any> = {};
816+
for (const key of Object.keys(v).sort()) {
817+
const next = normalize(v[key]);
818+
if (next !== undefined) out[key] = next;
819+
}
820+
return out;
821+
}
822+
823+
return v;
824+
};
825+
826+
return JSON.stringify(normalize(value));
827+
}
828+
829+
/**
830+
* Generate a stable hash of a config object
831+
* Filters out functions and non-serializable values
832+
* Sorts keys for stability
833+
*/
834+
export function hashConfig(config: Record<string, SerializableValue>): string {
835+
const serialized = stableStringify(config);
836+
return crypto.createHash("md5").update(serialized).digest("hex").slice(0, 12);
837+
}
838+
778839
/**
779840
* Create and start a translation server
780841
*/
@@ -800,7 +861,6 @@ export async function startOrGetTranslationServer(
800861
options: TranslationServerOptions,
801862
): Promise<{ server: TranslationServer; url: string }> {
802863
const server = new TranslationServer(options);
803-
// TODO (AleksandrSl 03/12/2025): Health endpoint should return the config and we should check if it matched the current one.
804864
const url = await server.startOrGetUrl();
805865
return { server, url };
806866
}

cmp/compiler/src/utils/hash.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export function generateTranslationHash(
2424
context: Record<string, any>,
2525
): string {
2626
const input = `${sourceText}::${Object.entries(context)
27+
.sort((a, b) => a[0].localeCompare(b[0]))
2728
.map(([key, value]) => `${key}:${value}`)
2829
.join("::")}`;
2930
return crypto.createHash("md5").update(input).digest("hex").substring(0, 12); // Use first 12 chars for brevity

0 commit comments

Comments
 (0)