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; + } } }