diff --git a/packages/sdk/src/index.spec.ts b/packages/sdk/src/index.spec.ts
index a88277fdf..69b04c1f8 100644
--- a/packages/sdk/src/index.spec.ts
+++ b/packages/sdk/src/index.spec.ts
@@ -72,6 +72,7 @@ describe("ReplexicaEngine", () => {
targetLocale: "es",
},
undefined,
+ undefined,
);
// Verify the final HTML structure
@@ -86,4 +87,169 @@ describe("ReplexicaEngine", () => {
expect(result).toContain('const doNotTranslate = "this text should be ignored"');
});
});
+
+ describe("AbortController support", () => {
+ it("should abort localizeText when signal is triggered", async () => {
+ const engine = new LingoDotDevEngine({ apiKey: "test" });
+ const mockFetch = vi.spyOn(global, "fetch").mockImplementation(async (url, options) => {
+ // Check if the request was aborted
+ if (options?.signal?.aborted) {
+ throw new DOMException("The operation was aborted.", "AbortError");
+ }
+
+ // Add a delay to simulate network request
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ // After the delay, check again if aborted during the delay
+ if (options?.signal?.aborted) {
+ throw new DOMException("The operation was aborted.", "AbortError");
+ }
+
+ return new Response(JSON.stringify({ data: { text: "translated text" } }));
+ });
+
+ const controller = new AbortController();
+ const signal = controller.signal;
+
+ // Start the request first, then abort after a small delay
+ const promise = engine.localizeText("Hello world", {
+ sourceLocale: "en",
+ targetLocale: "es"
+ }, undefined, signal);
+
+ // Small delay to make sure the request starts
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ // Now abort
+ controller.abort();
+
+ await expect(promise).rejects.toThrow("Localization was aborted");
+ mockFetch.mockRestore();
+ });
+
+ it("should abort localizeHtml when signal is triggered", async () => {
+ const engine = new LingoDotDevEngine({ apiKey: "test" });
+
+ // Mock _localizeRaw to throw AbortError when signal is aborted
+ vi.spyOn(engine as any, "_localizeRaw").mockImplementation(async (...args: any[]) => {
+ const signal = args[3]; // signal is the 4th argument
+
+ if (signal?.aborted) {
+ const error = new DOMException("The operation was aborted.", "AbortError");
+ throw error;
+ }
+
+ // Add a delay to simulate network request
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ // Check again if aborted during the delay
+ if (signal?.aborted) {
+ const error = new DOMException("The operation was aborted.", "AbortError");
+ throw error;
+ }
+
+ return {};
+ });
+
+ const controller = new AbortController();
+ const signal = controller.signal;
+
+ const promise = engine.localizeHtml("
Test", {
+ sourceLocale: "en",
+ targetLocale: "es"
+ }, undefined, signal);
+
+ // Small delay to ensure the request starts
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ // Abort after the delay
+ controller.abort();
+
+ await expect(promise).rejects.toThrow("Localization was aborted");
+ });
+
+ it("should abort batchLocalizeText and propagate to all child operations", async () => {
+ const engine = new LingoDotDevEngine({ apiKey: "test" });
+
+ // Track how many times localizeText is called
+ let localizeTextCalls = 0;
+
+ // Mock localizeText to simulate aborting
+ vi.spyOn(engine, "localizeText").mockImplementation(async (text, params, callback, signal) => {
+ localizeTextCalls++;
+
+ if (signal?.aborted) {
+ throw new Error("Localization was aborted");
+ }
+
+ // Simulate a longer delay
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ // Check again if aborted during the delay
+ if (signal?.aborted) {
+ throw new Error("Localization was aborted");
+ }
+
+ return "translated";
+ });
+
+ const controller = new AbortController();
+ const signal = controller.signal;
+
+ const promise = engine.batchLocalizeText("Hello world", {
+ sourceLocale: "en",
+ targetLocales: ["es", "fr", "de", "it"]
+ }, signal);
+
+ // Start the process
+ await new Promise(resolve => setTimeout(resolve, 20));
+
+ // Abort after the process has started
+ controller.abort();
+
+ await expect(promise).rejects.toThrow("Localization was aborted");
+
+ // Verify that we attempted to start some localizeText calls
+ expect(localizeTextCalls).toBeGreaterThan(0);
+ });
+
+ it("should abort recognizeLocale operation when signal is triggered", async () => {
+ const engine = new LingoDotDevEngine({ apiKey: "test" });
+
+ vi.spyOn(global, "fetch").mockImplementation(async (...args: any[]) => {
+ const options = args[1] as RequestInit; // options is the 2nd argument
+
+ // Check if already aborted
+ if (options?.signal?.aborted) {
+ throw new DOMException("The operation was aborted.", "AbortError");
+ }
+
+ // Check if the abort signal is passed to fetch
+ expect(options?.signal).toBeDefined();
+
+ // Simulate a delay
+ await new Promise(resolve => setTimeout(resolve, 100));
+
+ // Check if aborted during the delay
+ if (options?.signal?.aborted) {
+ throw new DOMException("The operation was aborted.", "AbortError");
+ }
+
+ return new Response(JSON.stringify({ locale: "en" }));
+ });
+
+ const controller = new AbortController();
+ const signal = controller.signal;
+
+ const promise = engine.recognizeLocale("Hello world", signal);
+
+ // Small delay to ensure the request starts
+ await new Promise(resolve => setTimeout(resolve, 10));
+
+ // Abort after the request has started
+ controller.abort();
+
+ await expect(promise).rejects.toThrow("Locale recognition was aborted");
+ });
+ });
});
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index 6ccbc6968..2c08de1bc 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -40,6 +40,7 @@ export class LingoDotDevEngine {
* @param payload - The content to be localized
* @param params - Localization parameters including source/target locales and fast mode option
* @param progressCallback - Optional callback function to report progress (0-100)
+ * @param signal - Optional AbortSignal to cancel the request
* @returns Localized content
* @internal
*/
@@ -49,9 +50,14 @@ export class LingoDotDevEngine {
progressCallback?: (
progress: number,
sourceChunk: Record,
- processedChunk: Record
- ) => void
+ processedChunk: Record,
+ ) => void,
+ signal?: AbortSignal,
): Promise> {
+ if (signal?.aborted) {
+ throw new Error("Localization was aborted");
+ }
+
const finalPayload = payloadSchema.parse(payload);
const finalParams = localizationParamsSchema.parse(params);
@@ -60,6 +66,10 @@ export class LingoDotDevEngine {
const workflowId = createId();
for (let i = 0; i < chunkedPayload.length; i++) {
+ if (signal?.aborted) {
+ throw new Error("Localization was aborted");
+ }
+
const chunk = chunkedPayload[i];
const percentageCompleted = Math.round(((i + 1) / chunkedPayload.length) * 100);
@@ -68,7 +78,8 @@ export class LingoDotDevEngine {
finalParams.targetLocale,
{ data: chunk, reference: params.reference },
workflowId,
- params.fast || false
+ params.fast || false,
+ signal,
);
if (progressCallback) {
@@ -86,6 +97,7 @@ export class LingoDotDevEngine {
* @param sourceLocale - Source locale
* @param targetLocale - Target locale
* @param payload - Payload containing the chunk to be localized
+ * @param signal - Optional AbortSignal to cancel the request
* @returns Localized chunk
*/
private async localizeChunk(
@@ -96,46 +108,60 @@ export class LingoDotDevEngine {
reference?: Z.infer;
},
workflowId: string,
- fast: boolean
+ fast: boolean,
+ signal?: AbortSignal,
): Promise> {
- const res = await fetch(`${this.config.apiUrl}/i18n`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json; charset=utf-8",
- Authorization: `Bearer ${this.config.apiKey}`,
- },
- body: JSON.stringify(
- {
- params: { workflowId, fast },
- locale: {
- source: sourceLocale,
- target: targetLocale,
- },
- data: payload.data,
- reference: payload.reference,
- },
- null,
- 2
- ),
- });
+ if (signal?.aborted) {
+ throw new Error("Localization was aborted");
+ }
- if (!res.ok) {
- if (res.status === 400) {
- throw new Error(`Invalid request: ${res.statusText}`);
- } else {
- const errorText = await res.text();
- throw new Error(errorText);
+ try {
+ const res = await fetch(`${this.config.apiUrl}/i18n`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json; charset=utf-8",
+ Authorization: `Bearer ${this.config.apiKey}`,
+ },
+ body: JSON.stringify(
+ {
+ params: { workflowId, fast },
+ locale: {
+ source: sourceLocale,
+ target: targetLocale,
+ },
+ data: payload.data,
+ reference: payload.reference,
+ },
+ null,
+ 2,
+ ),
+ signal,
+ });
+
+ if (!res.ok) {
+ if (res.status === 400) {
+ throw new Error(`Invalid request: ${res.statusText}`);
+ } else {
+ const errorText = await res.text();
+ throw new Error(errorText);
+ }
}
- }
- const jsonResponse = await res.json();
+ const jsonResponse = await res.json();
- // when streaming the error is returned in the response body
- if (!jsonResponse.data && jsonResponse.error) {
- throw new Error(jsonResponse.error);
- }
+ // when streaming the error is returned in the response body
+ if (!jsonResponse.data && jsonResponse.error) {
+ throw new Error(jsonResponse.error);
+ }
- return jsonResponse.data || {};
+ return jsonResponse.data || {};
+ } catch (error: any) {
+ // Convert AbortError from fetch to our standard error message
+ if (error.name === "AbortError" || (error.message && error.message.includes("aborted"))) {
+ throw new Error("Localization was aborted");
+ }
+ throw error;
+ }
}
/**
@@ -194,6 +220,7 @@ export class LingoDotDevEngine {
* - targetLocale: The target language code (e.g., 'es')
* - fast: Optional boolean to enable fast mode (faster but potentially lower quality)
* @param progressCallback - Optional callback function to report progress (0-100)
+ * @param signal - Optional AbortSignal to cancel the request
* @returns A new object with the same structure but localized string values
*/
async localizeObject(
@@ -202,10 +229,11 @@ export class LingoDotDevEngine {
progressCallback?: (
progress: number,
sourceChunk: Record,
- processedChunk: Record
- ) => void
+ processedChunk: Record,
+ ) => void,
+ signal?: AbortSignal,
): Promise> {
- return this._localizeRaw(obj, params, progressCallback);
+ return this._localizeRaw(obj, params, progressCallback, signal);
}
/**
@@ -216,14 +244,16 @@ export class LingoDotDevEngine {
* - targetLocale: The target language code (e.g., 'es')
* - fast: Optional boolean to enable fast mode (faster for bigger batches)
* @param progressCallback - Optional callback function to report progress (0-100)
+ * @param signal - Optional AbortSignal to cancel the request
* @returns The localized text string
*/
async localizeText(
text: string,
params: Z.infer,
- progressCallback?: (progress: number) => void
+ progressCallback?: (progress: number) => void,
+ signal?: AbortSignal,
): Promise {
- const response = await this._localizeRaw({ text }, params, progressCallback);
+ const response = await this._localizeRaw({ text }, params, progressCallback, signal);
return response.text || "";
}
@@ -234,6 +264,7 @@ export class LingoDotDevEngine {
* - sourceLocale: The source language code (e.g., 'en')
* - targetLocales: An array of target language codes (e.g., ['es', 'fr'])
* - fast: Optional boolean to enable fast mode (for bigger batches)
+ * @param signal - Optional AbortSignal to cancel the request
* @returns An array of localized text strings
*/
async batchLocalizeText(
@@ -242,19 +273,35 @@ export class LingoDotDevEngine {
sourceLocale: LocaleCode;
targetLocales: LocaleCode[];
fast?: boolean;
- }
+ },
+ signal?: AbortSignal,
) {
- const responses = await Promise.all(
- params.targetLocales.map((targetLocale) =>
- this.localizeText(text, {
+ if (signal?.aborted) {
+ throw new Error("Localization was aborted");
+ }
+
+ const promises = params.targetLocales.map((targetLocale) => {
+ // Create a new signal for each request that will be aborted if the main signal is aborted
+ const controller = signal ? new AbortController() : undefined;
+ const localSignal = controller?.signal;
+
+ if (signal && controller) {
+ signal.addEventListener("abort", () => controller.abort(), { once: true });
+ }
+
+ return this.localizeText(
+ text,
+ {
sourceLocale: params.sourceLocale,
targetLocale,
fast: params.fast,
- })
- )
- );
+ },
+ undefined,
+ localSignal,
+ );
+ });
- return responses;
+ return Promise.all(promises);
}
/**
@@ -265,14 +312,16 @@ export class LingoDotDevEngine {
* - targetLocale: The target language code (e.g., 'es')
* - fast: Optional boolean to enable fast mode (faster but potentially lower quality)
* @param progressCallback - Optional callback function to report progress (0-100)
+ * @param signal - Optional AbortSignal to cancel the request
* @returns Array of localized chat messages with preserved structure
*/
async localizeChat(
chat: Array<{ name: string; text: string }>,
params: Z.infer,
- progressCallback?: (progress: number) => void
+ progressCallback?: (progress: number) => void,
+ signal?: AbortSignal,
): Promise> {
- const localized = await this._localizeRaw({ chat }, params, progressCallback);
+ const localized = await this._localizeRaw({ chat }, params, progressCallback, signal);
return Object.entries(localized).map(([key, value]) => ({
name: chat[parseInt(key.split("_")[1])].name,
@@ -289,150 +338,178 @@ export class LingoDotDevEngine {
* - targetLocale: The target language code (e.g., 'es')
* - fast: Optional boolean to enable fast mode (faster but potentially lower quality)
* @param progressCallback - Optional callback function to report progress (0-100)
+ * @param signal - Optional AbortSignal to cancel the request
* @returns The localized HTML document as a string, with updated lang attribute
*/
async localizeHtml(
html: string,
params: Z.infer,
- progressCallback?: (progress: number) => void
+ progressCallback?: (progress: number) => void,
+ signal?: AbortSignal,
): Promise {
- const jsdomPackage = await import("jsdom");
- const { JSDOM } = jsdomPackage;
- const dom = new JSDOM(html);
- const document = dom.window.document;
-
- const LOCALIZABLE_ATTRIBUTES: Record = {
- meta: ["content"],
- img: ["alt"],
- input: ["placeholder"],
- a: ["title"],
- };
- const UNLOCALIZABLE_TAGS = ["script", "style"];
-
- const extractedContent: Record = {};
-
- const getPath = (node: Node, attribute?: string): string => {
- const indices: number[] = [];
- let current = node as ChildNode;
- let rootParent = "";
-
- while (current) {
- const parent = current.parentElement as Element;
- if (!parent) break;
-
- if (parent === document.documentElement) {
- rootParent = current.nodeName.toLowerCase();
- break;
- }
+ if (signal?.aborted) {
+ throw new Error("Localization was aborted");
+ }
- const siblings = Array.from(parent.childNodes).filter(
- (n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim())
- );
- const index = siblings.indexOf(current);
- if (index !== -1) {
- indices.unshift(index);
+ try {
+ const jsdomPackage = await import("jsdom");
+ const { JSDOM } = jsdomPackage;
+ const dom = new JSDOM(html);
+ const document = dom.window.document;
+
+ const LOCALIZABLE_ATTRIBUTES: Record = {
+ meta: ["content"],
+ img: ["alt"],
+ input: ["placeholder"],
+ a: ["title"],
+ };
+ const UNLOCALIZABLE_TAGS = ["script", "style"];
+
+ const extractedContent: Record = {};
+
+ const getPath = (node: Node, attribute?: string): string => {
+ const indices: number[] = [];
+ let current = node as ChildNode;
+ let rootParent = "";
+
+ while (current) {
+ const parent = current.parentElement as Element;
+ if (!parent) break;
+
+ if (parent === document.documentElement) {
+ rootParent = current.nodeName.toLowerCase();
+ break;
+ }
+
+ const siblings = Array.from(parent.childNodes).filter(
+ (n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()),
+ );
+ const index = siblings.indexOf(current);
+ if (index !== -1) {
+ indices.unshift(index);
+ }
+ current = parent;
}
- current = parent;
- }
- const basePath = rootParent ? `${rootParent}/${indices.join("/")}` : indices.join("/");
- return attribute ? `${basePath}#${attribute}` : basePath;
- };
+ const basePath = rootParent ? `${rootParent}/${indices.join("/")}` : indices.join("/");
+ return attribute ? `${basePath}#${attribute}` : basePath;
+ };
- const processNode = (node: Node) => {
- let parent = node.parentElement;
- while (parent) {
- if (UNLOCALIZABLE_TAGS.includes(parent.tagName.toLowerCase())) {
- return;
+ const processNode = (node: Node) => {
+ let parent = node.parentElement;
+ while (parent) {
+ if (UNLOCALIZABLE_TAGS.includes(parent.tagName.toLowerCase())) {
+ return;
+ }
+ parent = parent.parentElement;
}
- parent = parent.parentElement;
- }
- if (node.nodeType === 3) {
- const text = node.textContent?.trim() || "";
- if (text) {
- extractedContent[getPath(node)] = text;
+ if (node.nodeType === 3) {
+ const text = node.textContent?.trim() || "";
+ if (text) {
+ extractedContent[getPath(node)] = text;
+ }
+ } else if (node.nodeType === 1) {
+ const element = node as Element;
+ const tagName = element.tagName.toLowerCase();
+
+ const attributes = LOCALIZABLE_ATTRIBUTES[tagName] || [];
+ attributes.forEach((attr) => {
+ const value = element.getAttribute(attr);
+ if (value) {
+ extractedContent[getPath(element, attr)] = value;
+ }
+ });
+
+ Array.from(element.childNodes)
+ .filter((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()))
+ .forEach(processNode);
}
- } else if (node.nodeType === 1) {
- const element = node as Element;
- const tagName = element.tagName.toLowerCase();
-
- const attributes = LOCALIZABLE_ATTRIBUTES[tagName] || [];
- attributes.forEach((attr) => {
- const value = element.getAttribute(attr);
- if (value) {
- extractedContent[getPath(element, attr)] = value;
+ };
+
+ Array.from(document.head.childNodes)
+ .filter((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()))
+ .forEach(processNode);
+ Array.from(document.body.childNodes)
+ .filter((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()))
+ .forEach(processNode);
+
+ const localizedContent = await this._localizeRaw(extractedContent, params, progressCallback, signal);
+
+ // Update the DOM with localized content
+ document.documentElement.setAttribute("lang", params.targetLocale);
+
+ Object.entries(localizedContent).forEach(([path, value]) => {
+ const [nodePath, attribute] = path.split("#");
+ const [rootTag, ...indices] = nodePath.split("/");
+
+ let parent: Element = rootTag === "head" ? document.head : document.body;
+ let current: Node | null = parent;
+
+ for (const index of indices) {
+ const siblings = Array.from(parent.childNodes).filter(
+ (n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()),
+ );
+ current = siblings[parseInt(index)] || null;
+ if (current?.nodeType === 1) {
+ parent = current as Element;
}
- });
-
- Array.from(element.childNodes)
- .filter((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()))
- .forEach(processNode);
- }
- };
-
- Array.from(document.head.childNodes)
- .filter((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()))
- .forEach(processNode);
- Array.from(document.body.childNodes)
- .filter((n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim()))
- .forEach(processNode);
-
- const localizedContent = await this._localizeRaw(extractedContent, params, progressCallback);
-
- // Update the DOM with localized content
- document.documentElement.setAttribute("lang", params.targetLocale);
-
- Object.entries(localizedContent).forEach(([path, value]) => {
- const [nodePath, attribute] = path.split("#");
- const [rootTag, ...indices] = nodePath.split("/");
-
- let parent: Element = rootTag === "head" ? document.head : document.body;
- let current: Node | null = parent;
-
- for (const index of indices) {
- const siblings = Array.from(parent.childNodes).filter(
- (n) => n.nodeType === 1 || (n.nodeType === 3 && n.textContent?.trim())
- );
- current = siblings[parseInt(index)] || null;
- if (current?.nodeType === 1) {
- parent = current as Element;
}
- }
- if (current) {
- if (attribute) {
- (current as Element).setAttribute(attribute, value);
- } else {
- current.textContent = value;
+ if (current) {
+ if (attribute) {
+ (current as Element).setAttribute(attribute, value);
+ } else {
+ current.textContent = value;
+ }
}
- }
- });
+ });
- return dom.serialize();
+ return dom.serialize();
+ } catch (error: any) {
+ // Convert AbortError from fetch to our standard error message
+ if (error.name === "AbortError" || (error.message && error.message.includes("aborted"))) {
+ throw new Error("Localization was aborted");
+ }
+ throw error;
+ }
}
/**
* Detect the language of a given text
* @param text - The text to analyze
+ * @param signal - Optional AbortSignal to cancel the request
* @returns Promise resolving to a locale code (e.g., 'en', 'es', 'fr')
*/
- async recognizeLocale(text: string): Promise {
- const response = await fetch(`${this.config.apiUrl}/recognize`, {
- method: "POST",
- headers: {
- "Content-Type": "application/json; charset=utf-8",
- Authorization: `Bearer ${this.config.apiKey}`,
- },
- body: JSON.stringify({ text }),
- });
-
- if (!response.ok) {
- throw new Error(`Error recognizing locale: ${response.statusText}`);
+ async recognizeLocale(text: string, signal?: AbortSignal): Promise {
+ if (signal?.aborted) {
+ throw new Error("Locale recognition was aborted");
}
- const jsonResponse = await response.json();
- return jsonResponse.locale;
+ try {
+ const response = await fetch(`${this.config.apiUrl}/recognize`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json; charset=utf-8",
+ Authorization: `Bearer ${this.config.apiKey}`,
+ },
+ body: JSON.stringify({ text }),
+ signal,
+ });
+
+ if (!response.ok) {
+ throw new Error(`Error recognizing locale: ${response.statusText}`);
+ }
+
+ const jsonResponse = await response.json();
+ return jsonResponse.locale;
+ } catch (error: any) {
+ // Convert AbortError from fetch to our standard error message
+ if (error.name === "AbortError" || (error.message && error.message.includes("aborted"))) {
+ throw new Error("Locale recognition was aborted");
+ }
+ throw error;
+ }
}
}