Skip to content

Commit cb2aa0f

Browse files
authored
feat(sdk): add AbortController support so user can cancel API request (#998)
The underlying `fetch` handles AbortSignal naturally.
1 parent 864c305 commit cb2aa0f

File tree

4 files changed

+319
-8
lines changed

4 files changed

+319
-8
lines changed

.changeset/short-parents-applaud.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
"@lingo.dev/_sdk": minor
3+
"lingo.dev": minor
4+
---
5+
6+
Added support for AbortController to all public SDK methods, enabling consumers to cancel long-running operations using the standard AbortController API. Refactored internal methods to propagate AbortSignal and check for abortion between batch chunks. Updated fetch calls to use AbortSignal for network request cancellation.
Lines changed: 270 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,270 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { LingoDotDevEngine } from "../src/index.js";
3+
4+
// Mock fetch globally
5+
global.fetch = vi.fn();
6+
7+
describe("AbortController Support", () => {
8+
let engine: LingoDotDevEngine;
9+
10+
beforeEach(() => {
11+
engine = new LingoDotDevEngine({
12+
apiKey: "test-key",
13+
apiUrl: "https://test.api.com",
14+
});
15+
vi.clearAllMocks();
16+
});
17+
18+
describe("localizeText", () => {
19+
it("should pass AbortSignal to fetch", async () => {
20+
const controller = new AbortController();
21+
const mockResponse = {
22+
ok: true,
23+
json: vi.fn().mockResolvedValue({ data: { text: "Hola" } }),
24+
};
25+
(global.fetch as any).mockResolvedValue(mockResponse);
26+
27+
await engine.localizeText(
28+
"Hello",
29+
{ sourceLocale: "en", targetLocale: "es" },
30+
undefined,
31+
controller.signal,
32+
);
33+
34+
expect(global.fetch).toHaveBeenCalledWith(
35+
"https://test.api.com/i18n",
36+
expect.objectContaining({
37+
signal: controller.signal,
38+
}),
39+
);
40+
});
41+
42+
it("should throw error when operation is aborted", async () => {
43+
const controller = new AbortController();
44+
controller.abort();
45+
46+
await expect(
47+
engine.localizeText(
48+
"Hello",
49+
{ sourceLocale: "en", targetLocale: "es" },
50+
undefined,
51+
controller.signal,
52+
),
53+
).rejects.toThrow("Operation was aborted");
54+
});
55+
});
56+
57+
describe("localizeObject", () => {
58+
it("should pass AbortSignal to internal method", async () => {
59+
const controller = new AbortController();
60+
const mockResponse = {
61+
ok: true,
62+
json: vi.fn().mockResolvedValue({ data: { key: "valor" } }),
63+
};
64+
(global.fetch as any).mockResolvedValue(mockResponse);
65+
66+
await engine.localizeObject(
67+
{ key: "value" },
68+
{ sourceLocale: "en", targetLocale: "es" },
69+
undefined,
70+
controller.signal,
71+
);
72+
73+
expect(global.fetch).toHaveBeenCalledWith(
74+
"https://test.api.com/i18n",
75+
expect.objectContaining({
76+
signal: controller.signal,
77+
}),
78+
);
79+
});
80+
});
81+
82+
describe("localizeHtml", () => {
83+
it("should pass AbortSignal to internal method", async () => {
84+
const controller = new AbortController();
85+
const mockResponse = {
86+
ok: true,
87+
json: vi.fn().mockResolvedValue({ data: { "body/0": "Hola" } }),
88+
};
89+
(global.fetch as any).mockResolvedValue(mockResponse);
90+
91+
// Mock JSDOM
92+
const mockJSDOM = {
93+
JSDOM: vi.fn().mockImplementation(() => ({
94+
window: {
95+
document: {
96+
documentElement: {
97+
setAttribute: vi.fn(),
98+
},
99+
head: {
100+
childNodes: [],
101+
},
102+
body: {
103+
childNodes: [
104+
{
105+
nodeType: 3,
106+
textContent: "Hello",
107+
parentElement: null,
108+
},
109+
],
110+
},
111+
},
112+
},
113+
serialize: vi.fn().mockReturnValue("<html><body>Hola</body></html>"),
114+
})),
115+
};
116+
117+
// Mock dynamic import
118+
vi.doMock("jsdom", () => mockJSDOM);
119+
120+
await engine.localizeHtml(
121+
"<html><body>Hello</body></html>",
122+
{ sourceLocale: "en", targetLocale: "es" },
123+
undefined,
124+
controller.signal,
125+
);
126+
127+
expect(global.fetch).toHaveBeenCalledWith(
128+
"https://test.api.com/i18n",
129+
expect.objectContaining({
130+
signal: controller.signal,
131+
}),
132+
);
133+
});
134+
});
135+
136+
describe("localizeChat", () => {
137+
it("should pass AbortSignal to internal method", async () => {
138+
const controller = new AbortController();
139+
const mockResponse = {
140+
ok: true,
141+
json: vi.fn().mockResolvedValue({ data: { chat_0: "Hola" } }),
142+
};
143+
(global.fetch as any).mockResolvedValue(mockResponse);
144+
145+
await engine.localizeChat(
146+
[{ name: "User", text: "Hello" }],
147+
{ sourceLocale: "en", targetLocale: "es" },
148+
undefined,
149+
controller.signal,
150+
);
151+
152+
expect(global.fetch).toHaveBeenCalledWith(
153+
"https://test.api.com/i18n",
154+
expect.objectContaining({
155+
signal: controller.signal,
156+
}),
157+
);
158+
});
159+
});
160+
161+
describe("batchLocalizeText", () => {
162+
it("should pass AbortSignal to individual localizeText calls", async () => {
163+
const controller = new AbortController();
164+
const mockResponse = {
165+
ok: true,
166+
json: vi.fn().mockResolvedValue({ data: { text: "Hola" } }),
167+
};
168+
(global.fetch as any).mockResolvedValue(mockResponse);
169+
170+
await engine.batchLocalizeText(
171+
"Hello",
172+
{
173+
sourceLocale: "en",
174+
targetLocales: ["es", "fr"],
175+
},
176+
controller.signal,
177+
);
178+
179+
expect(global.fetch).toHaveBeenCalledTimes(2);
180+
expect(global.fetch).toHaveBeenCalledWith(
181+
"https://test.api.com/i18n",
182+
expect.objectContaining({
183+
signal: controller.signal,
184+
}),
185+
);
186+
});
187+
});
188+
189+
describe("recognizeLocale", () => {
190+
it("should pass AbortSignal to fetch", async () => {
191+
const controller = new AbortController();
192+
const mockResponse = {
193+
ok: true,
194+
json: vi.fn().mockResolvedValue({ locale: "en" }),
195+
};
196+
(global.fetch as any).mockResolvedValue(mockResponse);
197+
198+
await engine.recognizeLocale("Hello world", controller.signal);
199+
200+
expect(global.fetch).toHaveBeenCalledWith(
201+
"https://test.api.com/recognize",
202+
expect.objectContaining({
203+
signal: controller.signal,
204+
}),
205+
);
206+
});
207+
});
208+
209+
describe("whoami", () => {
210+
it("should pass AbortSignal to fetch", async () => {
211+
const controller = new AbortController();
212+
const mockResponse = {
213+
ok: true,
214+
json: vi
215+
.fn()
216+
.mockResolvedValue({ email: "[email protected]", id: "123" }),
217+
};
218+
(global.fetch as any).mockResolvedValue(mockResponse);
219+
220+
await engine.whoami(controller.signal);
221+
222+
expect(global.fetch).toHaveBeenCalledWith(
223+
"https://test.api.com/whoami",
224+
expect.objectContaining({
225+
signal: controller.signal,
226+
}),
227+
);
228+
});
229+
});
230+
231+
describe("Batch operations abortion", () => {
232+
it("should abort between chunks in _localizeRaw", async () => {
233+
const controller = new AbortController();
234+
235+
// Create a large payload that will be split into multiple chunks
236+
const largePayload: Record<string, string> = {};
237+
for (let i = 0; i < 100; i++) {
238+
largePayload[`key${i}`] = `value${i}`.repeat(50); // Make values long enough
239+
}
240+
241+
const mockResponse = {
242+
ok: true,
243+
json: vi.fn().mockResolvedValue({ data: { key0: "processed" } }),
244+
};
245+
246+
// Mock fetch to abort the controller after the first call
247+
let callCount = 0;
248+
(global.fetch as any).mockImplementation(async () => {
249+
callCount++;
250+
if (callCount === 1) {
251+
// Abort immediately after first call starts
252+
controller.abort();
253+
}
254+
return mockResponse;
255+
});
256+
257+
await expect(
258+
engine._localizeRaw(
259+
largePayload,
260+
{ sourceLocale: "en", targetLocale: "es" },
261+
undefined,
262+
controller.signal,
263+
),
264+
).rejects.toThrow("Operation was aborted");
265+
266+
// Should have made at least one call
267+
expect(callCount).toBeGreaterThan(0);
268+
});
269+
});
270+
});

packages/sdk/src/index.spec.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ describe("ReplexicaEngine", () => {
7474
targetLocale: "es",
7575
},
7676
undefined,
77+
undefined, // AbortSignal
7778
);
7879

7980
// Verify the final HTML structure

0 commit comments

Comments
 (0)